Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

For anyone who wants this: Rewrote to use React hooks, Typescript & support CSS variables along with a bit more iOS esque look #129

Open
johnstack94 opened this issue Oct 5, 2024 · 0 comments

Comments

@johnstack94
Copy link

First off all, sorry to hijack the issues tab but seeing as discussions isn't enabled on this repo I thought what the heck.

Intro

I needed a bit more modifications and needed the component to work in React 19. I also love Typescript so added that too. A few subjective improvements as well. The API should remain the same. I haven't tested all functionality but it seems to work just fine on my end.

Updates

  • Refactor to use React hooks
  • Add Typescript
  • Add CSS variables support for colors

Code

getBackgroundColor.ts

/**
 * Converts a shorthand hex color to a full 6-digit hex color.
 * @param color The shorthand hex color string (e.g., "#abc").
 * @returns The full 6-digit hex color string (e.g., "#aabbcc").
 */
export function convertShorthandColor(color: string): string {
  if (color.length === 7) {
    return color;
  }
  let sixDigitColor = "#";
  for (let i = 1; i < 4; i += 1) {
    sixDigitColor += color[i] + color[i];
  }
  return sixDigitColor;
}

/**
 * Converts an RGB color string to a hex color string.
 * @param rgb The RGB color string (e.g., "rgb(255, 255, 255)").
 * @returns The hex color string (e.g., "#ffffff").
 */
function rgbToHex(rgb: string): string {
  const result = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/i.exec(rgb);
  if (!result) {
    console.warn(`Invalid RGB color format: "${rgb}". Using fallback #000000.`);
    return "#000000";
  }
  const r = parseInt(result[1], 10);
  const g = parseInt(result[2], 10);
  const b = parseInt(result[3], 10);
  return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}

/**
 * Resolves a color string that may include CSS variables to its computed hex value.
 * Handles both hexadecimal and RGB color formats.
 * @param color The color string, either a hex code or a CSS variable (e.g., "var(--gray-200)").
 * @returns The resolved hex color string, or a fallback if resolution fails.
 */
export function resolveColor(color: string): string {
  if (color.startsWith("var(")) {
    if (typeof window === "undefined" || !document) {
      console.warn(
        `Cannot resolve CSS variables on the server. Using fallback color: "${color}".`
      );
      return "#000000"; // Fallback color
    }

    try {
      const variableName = color.slice(4, -1).trim();
      const resolved = getComputedStyle(document.documentElement)
        .getPropertyValue(variableName)
        .trim();

      if (!resolved) {
        console.warn(
          `CSS variable "${variableName}" is not defined. Using fallback color #000000.`
        );
        return "#000000"; // Fallback color
      }

      // If the resolved color is another CSS variable, attempt to resolve it recursively
      if (resolved.startsWith("var(")) {
        return resolveColor(resolved);
      }

      // Handle RGB and HEX formats
      if (/^rgb/i.test(resolved)) {
        return rgbToHex(resolved);
      }

      if (/^#([0-9A-F]{3}){1,2}$/i.test(resolved)) {
        return convertShorthandColor(resolved);
      }

      console.warn(
        `Resolved color "${resolved}" for variable "${variableName}" is not a valid HEX or RGB color. Using fallback #000000.`
      );
      return "#000000"; // Fallback color
    } catch (error) {
      console.error(
        `Failed to resolve color: "${color}". Using fallback #000000.`,
        error
      );
      return "#000000";
    }
  }

  // If the color is in RGB format, convert it to HEX
  if (/^rgb/i.test(color)) {
    return rgbToHex(color);
  }

  // If the color is a shorthand hex, convert it to 6-digit hex
  if (/^#([0-9A-F]{3})$/i.test(color)) {
    return convertShorthandColor(color);
  }

  // If the color is a full hex, return it as is
  if (/^#([0-9A-F]{6})$/i.test(color)) {
    return color;
  }

  console.warn(`Unsupported color format: "${color}". Using fallback #000000.`);
  return "#000000"; // Fallback color
}

/**
 * Calculates the interpolated background color based on the position.
 * @param pos Current position of the handle.
 * @param checkedPos Position when the switch is checked.
 * @param uncheckedPos Position when the switch is unchecked.
 * @param offColor Hex color when the switch is off.
 * @param onColor Hex color when the switch is on.
 * @returns The interpolated hex color string.
 */
export function createBackgroundColor(
  pos: number,
  checkedPos: number,
  uncheckedPos: number,
  offColor: string,
  onColor: string
): string {
  const relativePos = (pos - uncheckedPos) / (checkedPos - uncheckedPos);
  if (relativePos <= 0) {
    return offColor;
  }
  if (relativePos >= 1) {
    return onColor;
  }

  let newColor = "#";
  for (let i = 1; i < 6; i += 2) {
    const offComponent = parseInt(offColor.substr(i, 2), 16);
    const onComponent = parseInt(onColor.substr(i, 2), 16);
    const weightedValue = Math.round(
      (1 - relativePos) * offComponent + relativePos * onComponent
    );
    let newComponent = weightedValue.toString(16);
    if (newComponent.length === 1) {
      newComponent = `0${newComponent}`;
    }
    newColor += newComponent;
  }
  return newColor;
}

/**
 * Gets the background color based on the current position and color settings.
 * Resolves CSS variables to their computed hex values before interpolation.
 * @param pos Current position of the handle.
 * @param checkedPos Position when the switch is checked.
 * @param uncheckedPos Position when the switch is unchecked.
 * @param offColor Hex color or CSS variable when the switch is off.
 * @param onColor Hex color or CSS variable when the switch is on.
 * @returns The calculated background color as a hex string.
 */
export default function getBackgroundColor(
  pos: number,
  checkedPos: number,
  uncheckedPos: number,
  offColor: string,
  onColor: string
): string {
  const resolvedOffColor = resolveColor(offColor);
  const resolvedOnColor = resolveColor(onColor);
  const sixDigitOffColor = convertShorthandColor(resolvedOffColor);
  const sixDigitOnColor = convertShorthandColor(resolvedOnColor);
  return createBackgroundColor(
    pos,
    checkedPos,
    uncheckedPos,
    sixDigitOffColor,
    sixDigitOnColor
  );
}

Switch.tsx

import React, {
  useState,
  useEffect,
  useRef,
  useCallback,
  CSSProperties,
  ReactElement,
  ChangeEvent,
  MouseEvent as ReactMouseEvent,
  TouchEvent as ReactTouchEvent,
} from "react";
import getBackgroundColor from "./getBackgroundColor";

interface SwitchProps {
  checked: boolean;
  onChange: (checked: boolean, event: Event, id?: string) => void;
  disabled?: boolean;
  offColor?: string;
  onColor?: string;
  offHandleColor?: string;
  onHandleColor?: string;
  handleDiameter?: number;
  uncheckedIcon?: boolean | ReactElement;
  checkedIcon?: boolean | ReactElement;
  boxShadow?: string | null;
  borderRadius?: number;
  activeBoxShadow?: string;
  uncheckedHandleIcon?: ReactElement;
  checkedHandleIcon?: ReactElement;
  height?: number;
  width?: number;
  id?: string;
  className?: string;
}

const Switch: React.FC<SwitchProps> = ({
  checked,
  onChange,
  disabled = false,
  offColor = "#d1d5db",
  onColor = "#22c55e",
  offHandleColor = "#fff",
  onHandleColor = "#fff",
  handleDiameter,
  uncheckedIcon,
  checkedIcon,
  checkedHandleIcon,
  uncheckedHandleIcon,
  boxShadow = undefined,
  activeBoxShadow = "0 2px 12px rgba(0,0,0,0.24)",
  height = 31,
  width = 51,
  borderRadius,
  id,
  className,
  ...rest
}) => {
  // Refs
  const inputRef = useRef<HTMLInputElement | null>(null);
  const isMountedRef = useRef<boolean>(false);
  const lastDragAtRef = useRef<number>(0);
  const lastKeyUpAtRef = useRef<number>(0);

  // Derived positions
  const computedHandleDiameter =
    handleDiameter !== undefined ? handleDiameter : height - 4;
  const checkedPos = Math.max(
    width - height,
    width - (height + computedHandleDiameter) / 2
  );
  const uncheckedPos = Math.max(0, (height - computedHandleDiameter) / 2);

  // State
  const [pos, setPos] = useState<number>(checked ? checkedPos : uncheckedPos);
  const [startX, setStartX] = useState<number | undefined>(undefined);
  const [hasOutline, setHasOutline] = useState<boolean>(false);
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const [dragStartingTime, setDragStartingTime] = useState<number | undefined>(
    undefined
  );

  // Effect to handle component mount/unmount
  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  // Effect to update position when 'checked' prop changes
  useEffect(() => {
    const newPos = checked ? checkedPos : uncheckedPos;
    setPos(newPos);
  }, [checked, checkedPos, uncheckedPos]);

  // Handlers

  const onDragStart = useCallback((clientX: number) => {
    inputRef.current?.focus();
    setStartX(clientX);
    setHasOutline(true);
    setDragStartingTime(Date.now());
  }, []);

  const onDrag = useCallback(
    (clientX: number) => {
      if (startX === undefined) return;
      const mousePos = (checked ? checkedPos : uncheckedPos) + clientX - startX;

      if (!isDragging && clientX !== startX) {
        setIsDragging(true);
      }

      const newPos = Math.min(checkedPos, Math.max(uncheckedPos, mousePos));

      // Prevent unnecessary re-renders
      if (newPos !== pos) {
        setPos(newPos);
      }
    },
    [checked, checkedPos, uncheckedPos, isDragging, pos, startX]
  );

  const onDragStop = useCallback(
    (event: MouseEvent | TouchEvent) => {
      const halfwayCheckpoint = (checkedPos + uncheckedPos) / 2;

      // Reset position to previous state to prevent it from getting stuck
      const prevPos = checked ? checkedPos : uncheckedPos;
      setPos(prevPos);

      // Determine if the drag was a simulated click
      const timeSinceStart = dragStartingTime
        ? Date.now() - dragStartingTime
        : 0;
      const isSimulatedClick = !isDragging || timeSinceStart < 250;

      // Determine if the drag moved the switch past the halfway point
      const isDraggedHalfway =
        (checked && pos <= halfwayCheckpoint) ||
        (!checked && pos >= halfwayCheckpoint);

      if (isSimulatedClick || isDraggedHalfway) {
        onChange(!checked, event, id);
      }

      setIsDragging(false);
      setHasOutline(false);
      lastDragAtRef.current = Date.now();
    },
    [
      checked,
      checkedPos,
      uncheckedPos,
      dragStartingTime,
      isDragging,
      pos,
      onChange,
      id,
    ]
  );

  const handleMouseMove = useCallback(
    (event: MouseEvent) => {
      event.preventDefault();
      onDrag(event.clientX);
    },
    [onDrag]
  );

  const handleMouseUp = useCallback(
    (event: MouseEvent) => {
      onDragStop(event);
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    },
    [onDragStop, handleMouseMove]
  );

  const handleMouseDown = useCallback(
    (event: ReactMouseEvent) => {
      event.preventDefault();
      // Ignore right-click and scroll
      if (typeof event.button === "number" && event.button !== 0) {
        return;
      }

      onDragStart(event.clientX);
      window.addEventListener("mousemove", handleMouseMove);
      window.addEventListener("mouseup", handleMouseUp);
    },
    [onDragStart, handleMouseMove, handleMouseUp]
  );

  const handleTouchStart = useCallback(
    (event: ReactTouchEvent) => {
      onDragStart(event.touches[0].clientX);
    },
    [onDragStart]
  );

  const handleTouchMove = useCallback(
    (event: TouchEvent) => {
      onDrag(event.touches[0].clientX);
    },
    [onDrag]
  );

  const handleTouchEnd = useCallback(
    (event: ReactTouchEvent) => {
      event.preventDefault();
      onDragStop(event);
    },
    [onDragStop]
  );

  const handleInputChange = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      // Prevent unintended changes triggered by drag events
      if (Date.now() - lastDragAtRef.current > 50) {
        onChange(!checked, event, id);
        // Prevent clicking the label from removing the outline
        if (Date.now() - lastKeyUpAtRef.current > 50) {
          setHasOutline(false);
        }
      }
    },
    [checked, onChange, id]
  );

  const handleKeyUp = useCallback(() => {
    lastKeyUpAtRef.current = Date.now();
  }, []);

  const handleSetHasOutline = useCallback(() => {
    setHasOutline(true);
  }, []);

  const handleUnsetHasOutline = useCallback(() => {
    setHasOutline(false);
  }, []);

  const handleClick = useCallback(
    (event: ReactMouseEvent) => {
      event.preventDefault();
      inputRef.current?.focus();
      onChange(!checked, event, id);
      setHasOutline(false);
    },
    [checked, onChange, id]
  );

  // Styles
  const rootStyle: CSSProperties = {
    position: "relative",
    display: "inline-block",
    textAlign: "left",
    opacity: disabled ? 0.5 : 1,
    direction: "ltr",
    borderRadius: height / 2,
    transition: "opacity 0.25s",
    touchAction: "none",
    WebkitTapHighlightColor: "rgba(0, 0, 0, 0)",
    userSelect: "none",
  };

  const backgroundStyle: CSSProperties = {
    height,
    width,
    margin: Math.max(0, (computedHandleDiameter - height) / 2),
    position: "relative",
    background: getBackgroundColor(
      pos,
      checkedPos,
      uncheckedPos,
      offColor,
      onColor
    ),
    borderRadius: typeof borderRadius === "number" ? borderRadius : height / 2,
    cursor: disabled ? "default" : "pointer",
    transition: isDragging ? undefined : "background 0.25s",
  };

  const checkedIconStyle: CSSProperties = {
    height,
    width: Math.min(
      height * 1.5,
      width - (computedHandleDiameter + height) / 2 + 1
    ),
    position: "relative",
    opacity: (pos - uncheckedPos) / (checkedPos - uncheckedPos),
    pointerEvents: "none",
    transition: isDragging ? undefined : "opacity 0.25s",
  };

  const uncheckedIconStyle: CSSProperties = {
    height,
    width: Math.min(
      height * 1.5,
      width - (computedHandleDiameter + height) / 2 + 1
    ),
    position: "absolute",
    opacity: 1 - (pos - uncheckedPos) / (checkedPos - uncheckedPos),
    right: 0,
    top: 0,
    pointerEvents: "none",
    transition: isDragging ? undefined : "opacity 0.25s",
  };

  const handleStyle: CSSProperties = {
    height: computedHandleDiameter,
    width: computedHandleDiameter,
    background: getBackgroundColor(
      pos,
      checkedPos,
      uncheckedPos,
      offHandleColor,
      onHandleColor
    ),
    display: "inline-block",
    cursor: disabled ? "default" : "pointer",
    borderRadius: typeof borderRadius === "number" ? borderRadius - 1 : "50%",
    position: "absolute",
    transform: `translateX(${pos}px)`,
    top: Math.max(0, (height - computedHandleDiameter) / 2),
    outline: 0,
    boxShadow: hasOutline ? activeBoxShadow : boxShadow || undefined,
    border: 0,
    transition: isDragging
      ? undefined
      : "background-color 0.25s, transform 0.25s, box-shadow 0.15s",
  };

  const uncheckedHandleIconStyle: CSSProperties = {
    height: computedHandleDiameter,
    width: computedHandleDiameter,
    opacity: Math.max(
      (1 - (pos - uncheckedPos) / (checkedPos - uncheckedPos) - 0.5) * 2,
      0
    ),
    position: "absolute",
    left: 0,
    top: 0,
    pointerEvents: "none",
    transition: isDragging ? undefined : "opacity 0.25s",
  };

  const checkedHandleIconStyle: CSSProperties = {
    height: computedHandleDiameter,
    width: computedHandleDiameter,
    opacity: Math.max(
      ((pos - uncheckedPos) / (checkedPos - uncheckedPos) - 0.5) * 2,
      0
    ),
    position: "absolute",
    left: 0,
    top: 0,
    pointerEvents: "none",
    transition: isDragging ? undefined : "opacity 0.25s",
  };

  const inputStyle: CSSProperties = {
    border: 0,
    clip: "rect(0 0 0 0)",
    height: 1,
    margin: -1,
    overflow: "hidden",
    padding: 0,
    position: "absolute",
    width: 1,
  };

  return (
    <div className={className} style={rootStyle}>
      <div
        className="react-switch-bg"
        style={backgroundStyle}
        onClick={disabled ? undefined : handleClick}
        onMouseDown={(e) => e.preventDefault()}
      >
        {checkedIcon && <div style={checkedIconStyle}>{checkedIcon}</div>}
        {uncheckedIcon && <div style={uncheckedIconStyle}>{uncheckedIcon}</div>}
      </div>
      <div
        className="react-switch-handle"
        style={handleStyle}
        onClick={(e) => e.preventDefault()}
        onMouseDown={disabled ? undefined : handleMouseDown}
        onTouchStart={disabled ? undefined : handleTouchStart}
        onTouchMove={disabled ? undefined : handleTouchMove}
        onTouchEnd={disabled ? undefined : handleTouchEnd}
        onTouchCancel={disabled ? undefined : handleUnsetHasOutline}
      >
        {uncheckedHandleIcon && (
          <div style={uncheckedHandleIconStyle}>{uncheckedHandleIcon}</div>
        )}
        {checkedHandleIcon && (
          <div style={checkedHandleIconStyle}>{checkedHandleIcon}</div>
        )}
      </div>
      <input
        type="checkbox"
        role="switch"
        aria-checked={checked}
        checked={checked}
        disabled={disabled}
        style={inputStyle}
        {...rest}
        /* The following props should not be overridden by ...rest */
        ref={inputRef}
        onFocus={handleSetHasOutline}
        onBlur={handleUnsetHasOutline}
        onKeyUp={handleKeyUp}
        onChange={handleInputChange}
      />
    </div>
  );
};

export default Switch;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant