trendmicro-frontend / tonic-ui

Tonic UI is a UI component library for React, built with Emotion and Styled System. It is designed to be easy to use and easy to customize.
https://trendmicro-frontend.github.io/tonic-ui
MIT License
125 stars 29 forks source link

Add `InlineToasts` and `useInlineToasts` examples for inline, non-intrusive notifications in modals and drawer scenarios #933

Closed cheton closed 3 weeks ago

cheton commented 2 months ago

Usage

Use the useInlineToasts hook to manage inline toast notifications:

const { toasts, notify: notifyInlineToast } = useInlineToasts();

Trigger an inline toast notification:

notifyInlineToast({
  appearance: 'error',
  content: (
    <Text>{i18n._('An unexpected error has occurred.')}</Text>
  ),
  duration: undefined,
});

Display inline toasts on a Modal:

<Modal>
  <ModalContent>
    <InlineToastContainer>
      <InlineToasts toasts={toasts} />
    </InlineToastContainer>
    <ModalHeader />
    <ModalBody />
    <ModalFooter />
  </ModalContent>
</Modal>

Components

InlineToastContainer

import {
  Box,
} from '@tonic-ui/react';
import React, { forwardRef } from 'react';

const InlineToastContainer = forwardRef((inProps, ref) => (
  <Box
    ref={ref}
    flexDirection="column"
    alignItems="center"
    position="absolute"
    top="12x"
    left="50%"
    transform="translateX(-50%)"
    width="max-content"
    maxWidth="80%" // up to 80% of the modal or drawer width
    zIndex="toast"
    {...inProps}
  />
));

InlineToastContainer.displayName = 'InlineToastContainer';

export default InlineToastContainer;

InlineToasts

import {
  Toast,
  ToastController,
  ToastTransition,
  useColorStyle,
} from '@tonic-ui/react';
import React from 'react';
import { TransitionGroup } from 'react-transition-group';

const InlineToasts = inProps => {
  const [colorStyle] = useColorStyle();
  const {
    toasts = [],
    ...rest
  } = inProps;

  return (
    <TransitionGroup
      component={null} // Pass in `component={null}` to avoid a wrapping `<div>` element
    >
      {toasts.map(toast => (
        <ToastTransition
          key={toast?.id}
          in={true}
          unmountOnExit
        >
          <ToastController
            duration={toast?.duration}
            onClose={toast?.onClose}
          >
            <Toast
              appearance={toast?.appearance}
              isClosable={toast?.isClosable}
              onClose={toast?.onClose}
              sx={{
                mb: '2x',
                minWidth: 280, // The toast has a minimum width of 280 pixels
                width: 'fit-content',
                boxShadow: colorStyle.shadow.thin,
              }}
            >
              {toast?.content}
            </Toast>
          </ToastController>
        </ToastTransition>
      ))}
    </TransitionGroup>
  );
};

InlineToasts.displayName = 'InlineToasts';

export default InlineToasts;

useInlineToasts

import { ensurePositiveInteger } from 'ensure-type';
import { useCallback, useMemo, useState } from 'react';

const uniqueId = (() => {
  let id = 0;
  return () => {
    id += 1;
    return String(id);
  };
})();

const useInlineToasts = (options) => {
  const maxToasts = ensurePositiveInteger(options?.maxToasts);
  const [toasts, setToasts] = useState([]);

  const notify = useCallback((options) => {
    const {
      appearance,
      content,
      duration = null,
      isClosable = true,
    } = { ...options };

    setToasts(prevState => {
      const id = uniqueId();
      const onClose = () => {
        setToasts(toasts => toasts.filter(x => x.id !== id));
      };
      // You can decide how many toasts you want to show at the same time depending on your use case
      const nextState = [
        ...prevState.slice(maxToasts > 1 ? -(maxToasts - 1) : prevState.length),
        {
          id,
          appearance,
          content,
          duration,
          isClosable,
          onClose,
        },
      ];
      return nextState;
    });
  }, [maxToasts]);

  const dismiss = useCallback(() => {
    setToasts([]);
  }, []);

  const context = useMemo(() => ({
    toasts,
    notify,
    dismiss,
  }), [toasts, notify, dismiss]);

  return context;
};

export default useInlineToasts;