Untitled

 avatar
unknown
plain_text
a year ago
9.2 kB
6
Indexable
/* eslint-disable no-nested-ternary */
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import styled from 'styled-components';

// Declare enum
const Event = { Show: 0, Dismiss: 1 } as const;
type Event = (typeof Event)[keyof typeof Event];
const Position = {
  TopLeft: 'top-left',
  TopCenter: 'top-center',
  TopRight: 'top-right',
  BottomLeft: 'bottom-left',
  BottomCenter: 'bottom-center',
  BottomRight: 'bottom-right'
} as const;
type Position = (typeof Position)[keyof typeof Position];

// Declare type
type SonnerProviderConfig = {
  maxSonner?: number;
  position?: Position;
};
type ToastData = {
  id?: number;
  title?: string;
  desc?: string;
  duration?: number;
};
type ToastDataInternal = Required<ToastData & { dissmiss: boolean }>;
type Callback = (...args: any[]) => void;
class EventManager {
  list = new Map<Event, Callback[]>();
  on(event: Event, callback: Callback) {
    this.list.has(event) || this.list.set(event, []);
    this.list.get(event)!.push(callback);
    return this;
  }
  off(event: Event, callback: Callback) {
    if (callback) {
      const cb = this.list.get(event)!.filter(cb => cb !== callback);
      this.list.set(event, cb);
      return this;
    }
    this.list.delete(event);
    return this;
  }
  emit(event: Event, ...args: any[]) {
    this.list.has(event) &&
      this.list.get(event)!.forEach((callback: Callback) => {
        callback(...args);
      });
  }
}

const eventManager = new EventManager();

const DEFAULT_DURATION = 3000;
let id = 0;
function generateId() {
  id += 1;
  return id;
}

function sonner(toast: ToastData): void;
function sonner(title: string, toastData?: ToastData): void;
function sonner(titleOrToast: ToastData | string, toastData: ToastData = {}) {
  let data: any;
  if (typeof titleOrToast === 'string')
    data = {
      ...toastData,
      id: toastData.id || generateId(),
      title: titleOrToast
    };
  else data = { ...titleOrToast, id: titleOrToast.id || generateId() };
  eventManager.emit(Event.Show, data);
  return data.id as number;
}
function dismiss(id?: number) {
  eventManager.emit(Event.Dismiss, id);
}
const toast = Object.assign(sonner, { dismiss });

const SonnerProvider = (
  props: React.PropsWithChildren<SonnerProviderConfig>
) => {
  const { maxSonner = 10, position = Position.TopCenter } = props;
  const [hovering, setHovering] = React.useState(false);
  const [toasts, setToasts] = React.useState<ToastDataInternal[]>([]);
  const [toastHeight] = React.useState<Map<number, number>>(new Map());

  const [y, x] = position.split('-');

  const showToast = React.useCallback(
    (toastData: ToastDataInternal) => {
      ReactDOM.flushSync(() =>
        setToasts(toasts => {
          const indexOfExistingToast = toasts.findIndex(
            t => t.id === toastData.id
          );
          // Update the toast if it already exists
          if (indexOfExistingToast !== -1) {
            return [
              ...toasts.slice(0, indexOfExistingToast),
              { ...toasts[indexOfExistingToast], ...sonner },
              ...toasts.slice(indexOfExistingToast + 1)
            ];
          }

          return [toastData, ...toasts].slice(0, maxSonner);
        })
      );
    },
    [maxSonner]
  );
  const removeToast = React.useCallback((id: number) => {
    toastHeight.delete(id);
    setToasts(toasts => toasts.filter(toast => toast.id !== id));
  }, []);
  const dissmissToast = React.useCallback(
    (id?: number) =>
      id
        ? setToasts(toasts =>
            toasts.map(t => (t.id === id ? { ...t, dissmiss: true } : t))
          )
        : setToasts(toasts =>
            toasts.map(t => {
              // eslint-disable-next-line no-param-reassign
              t.dissmiss = true;
              return t;
            })
          ),
    []
  );

  useEffect(() => {
    eventManager.on(Event.Show, showToast).on(Event.Dismiss, dissmissToast);
    return () => {
      eventManager.off(Event.Show, showToast).off(Event.Dismiss, dissmissToast);
    };
  }, [showToast, dissmissToast]);
  const total = toasts.length;
  let offset = 0;
  const styles: any = {};
  if (x === 'center') {
    styles.left = '50%';
    styles.transform = 'translateX(-50%)';
  } else {
    styles[x] = 10;
  }

  if (!toasts.length) return null;
  return (
    <section data-portal="sonner">
      <SonnerToaster
        data-sonner-toaster
        onMouseEnter={() => setHovering(true)}
        onMouseMove={() => setHovering(true)}
        onMouseLeave={() => setHovering(false)}
        style={{
          '--width': `${356}px`,
          ...styles,
          [y]: 10
        }}
      >
        {toasts.map((toast, index) => {
          const currentOffset = offset;
          offset += toastHeight.get(toast.id) || 0;
          return (
            <SonnerItem
              key={toast.id}
              remove={removeToast}
              toast={toast}
              hovering={hovering}
              total={total}
              index={index}
              toastHeight={toastHeight}
              offset={currentOffset}
              y={y}
            />
          );
        })}
      </SonnerToaster>
    </section>
  );
};
type SonnerItemProps = {
  toast: ToastDataInternal;
  remove(id?: number): void;
  hovering: boolean;
  total: number;
  index: number;
  toastHeight: Map<number, number>;
  offset: number;
  y: string;
};
const SonnerItem = (props: SonnerItemProps) => {
  const {
    toastHeight: mapHeight,
    toast,
    remove,
    hovering,
    total,
    index,
    offset,
    y
  } = props;
  const [mounted, setMounted] = React.useState(false);
  const [removed, setRemoved] = React.useState(false);

  const toastRef = React.useRef<HTMLLIElement>(null);
  const isVisible = index <= 2;

  const deleteToast = React.useCallback(() => {
    setRemoved(true);
    setTimeout(() => {
      remove(toast.id);
    }, 200);
  }, [toast]);
  React.useEffect(() => {
    setMounted(true);
  }, []);
  React.useLayoutEffect(() => {
    if (!mounted) return;
    const toastNode = toastRef.current!;
    const originalHeight = toastNode.style.height;
    toastNode.style.height = 'auto';

    const toastHeight = toastNode.getBoundingClientRect().height;
    toastNode.style.height = originalHeight;
    mapHeight.set(toast.id, toastHeight);
  }, [mounted, toast.title, toast.desc, toast.id]);
  React.useEffect(() => {
    let timeoutId: NodeJS.Timeout;

    if (!hovering) {
      timeoutId = setTimeout(deleteToast, toast.duration || DEFAULT_DURATION);
    }
    return () => {
      clearTimeout(timeoutId);
    };
  }, [hovering]);

  React.useEffect(() => {
    if (toast.dissmiss) deleteToast();
  }, [deleteToast, toast.dissmiss]);
  const direction = y === 'top' ? -1 : 1;
  const transform = mounted
    ? removed
      ? `translateY(${100 * direction}%)`
      : hovering
      ? `translateY(${-direction * (offset + 10 * index)}px)`
      : `translateY(${-direction * index * 10}px) scale(${-index * 0.05 + 1})`
    : `translateY(${100 * direction}%)`;

  return (
    <LiItem
      data-mounted={mounted}
      key={`toast_item_${toast.id}`}
      data-sonner-id={toast.id}
      style={{
        zIndex: total - index,
        transform,
        opacity: removed ? 0 : isVisible ? Number(mounted) : 0,
        pointerEvents: isVisible ? 'auto' : 'none',
        [y]: 0
      }}
      ref={toastRef}
    >
      <ToastItem>{toast.title}</ToastItem>
    </LiItem>
  );
};
const SonnerToaster = styled.ol`
  list-style: none;
  margin: 0;
  padding: 0;
  z-index: 999;
  position: fixed;
  width: var(--width);
  @media (max-width: 600px) {
    [data-sonner-toaster] {
      position: fixed;
      --mobile-offset: 16px;
      right: var(--mobile-offset);
      left: var(--mobile-offset);
      width: 100%;
    }
    [data-sonner-toaster] [data-sonner-toast] {
      left: 0;
      right: 0;
      width: calc(100% - 32px);
    }
    /* [data-sonner-toaster][data-x-position='left'] {
      left: var(--mobile-offset);
    }
    [data-sonner-toaster][data-y-position='bottom'] {
      bottom: 20px;
    }
    [data-sonner-toaster][data-y-position='top'] {
      top: 20px;
    }
    [data-sonner-toaster][data-x-position='center'] {
      left: var(--mobile-offset);
      right: var(--mobile-offset);
      transform: none;
    } */
  }
`;
const LiItem = styled.li`
  position: absolute;
  transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms;
  list-style: none;

  width: 356px;
  ::after {
    content: '';
    position: absolute;
    bottom: 100%;
    width: 100%;
    left: 0;
    height: 10px;
  }
`;
const ToastItem = styled.div`
  padding: 1rem;
  border: 1px solid black;
  border-radius: 0.25rem;
  background: white;
  border: 1px solid hsl(0, 0%, 93%);
  box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
`;

export default SonnerProvider;
export { toast as sonner };
Editor is loading...
Leave a Comment