All files / src/components toggle-switch.tsx

100% Statements 6/6
85.71% Branches 12/14
100% Functions 2/2
100% Lines 6/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105                                                                                    8x                           20x   20x 1x 1x       20x                                                                                
import { type FC } from 'react';
import cn from 'classnames';
 
import '../styles/components/toggle-switch.scss';
 
type Props = {
  header: string;
  /**
   * Icon rendered in a circular badge to the left of the header. Optional —
   * omit for a text-only toggle.
   */
  icon?: React.ReactNode;
  checked: boolean;
  isLoading?: boolean;
  onChange: (checked: boolean) => void;
  ariaLabel?: string;
  className?: string;
  /**
   * Compact variant: shrinks the knob and label proportionally so the toggle
   * fits in cramped contexts (sticky toolbars, dense forms).
   * @default false
   */
  compact?: boolean;
  /**
   * Stretch the toggle to fill its container's available width. Defaults to
   * `false` so the toggle hugs its content — the more predictable behaviour
   * for buttons. Set to `true` for full-bleed layouts (eg. settings panels).
   * @default false
   */
  fullWidth?: boolean;
  /**
   * Invisible decoy used to peg the toggle's intrinsic width to a stable
   * string. Useful when `header` swaps between values of different widths
   * (eg. "AI Annotations" ↔ "Loading…") and you want to avoid layout reflow
   * during the swap. Pass the longest string the consumer expects to swap to.
   *
   * Has no effect when combined with `fullWidth` — there the parent's width
   * dominates and the decoy can't shrink-wrap anything.
   */
  stableWidthLabel?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'>;
 
const ToggleSwitch: FC<Props> = ({
  header,
  icon,
  checked,
  isLoading = false,
  onChange,
  ariaLabel = 'Toggle',
  disabled = false,
  compact = false,
  fullWidth = false,
  stableWidthLabel,
  className = '',
  ...rest
}) => {
  const isOn = checked && !isLoading;
 
  const handleToggle = () => {
    Eif (!disabled) {
      onChange(!checked);
    }
  };
 
  return (
    <button
      type="button"
      className={cn(
        'toggle',
        {
          'toggle--on': isOn,
          'toggle--loading': isLoading,
          'toggle--disabled': disabled,
          'toggle--compact': compact,
          'toggle--full-width': fullWidth,
        },
        className
      )}
      role="switch"
      aria-checked={checked}
      aria-label={ariaLabel}
      disabled={disabled}
      onClick={handleToggle}
      {...rest}
    >
      <div className="toggle__content">
        {icon && <div className="toggle__icon">{icon}</div>}
        <div className="toggle__header">
          {header}
          {stableWidthLabel && (
            <div className="toggle__header__stable" aria-hidden="true">
              {stableWidthLabel}
            </div>
          )}
        </div>
        <div className="toggle__switch" aria-hidden="true">
          <span className="slider" />
        </div>
      </div>
    </button>
  );
};
 
export default ToggleSwitch;