Repository: laboratoriobridge/bold (ts)

Issue:
[TOAST] Actionable notification
# Propose a new pattern ✨

A toast is a non-modal dialog that appears and disappears (overlaying) with the purpose of displaying information that requires minimal user interaction. Toasts can close automatically after 5 seconds, or they may require manual close action.

An **actionable notification toast variant** displays non-critical information and allows for interactive elements. It can be highly disruptive, differing from toast default, once the actionable notification is read by screenreader when it appears.

 
### **Visual**
![image](https://github.com/laboratoriobridge/bold/assets/63730484/4ce0554b-bac0-4075-9143-9e3459c303b8)
![image](https://github.com/laboratoriobridge/bold/assets/63730484/9a74504f-0050-4996-9cb2-b0ad3c0515e7)

[Figma prototype.](https://www.figma.com/file/TE9FUDtlgVQ4FWlAPtTagxQU/Bold-Design-System?type=design&node-id=89645-3595&mode=design&t=yzDICXGhswJYxox9-4)

### **Context**
This pattern, which allows for interactive elements, should be used to display non-critical information in the user flow. Avoid using long messages since this component disappears automatically, which could make it difficult for users to read the entire message.

- When using Tab, the focus moves in logical order to buttons or links inside the toast.
- It shouldn't block the user from interacting with the interface. 
- Use the dismiss icon to close flags. The flag must close after an action is taken, or after 5 seconds. If the flag receives hover status, it should be kept open.
- Try to keep the text of the message concise: it can be up to two lines in length. 


### **State**
Badge: True/False

### **Accessibility** 
-  The toast message is read when it appears.
- When using Tab, the focus moves in logical order to buttons or links inside the toast.
- When given focus, space key and enter key activate any buttons inside. The toast can optionally be closed with Escape key.
- If it is possible to close the toast, focus then returns to a logical place in the page.
- The close button is given an ARIA label of aria-label="close", and the icon has aria-hidden="true" so it is ignored by assistive technologies. 




Pull request:
Adding Toast component
Closes #844 

Patch:
diff --git a/src/components/ActionableToast/ActionableToast.tsx b/src/components/ActionableToast/ActionableToast.tsx
new file mode 100644
index 000000000..d0f346766
--- /dev/null
+++ b/src/components/ActionableToast/ActionableToast.tsx
@@ -0,0 +1,164 @@
+import React, { CSSProperties, useRef, useEffect, useCallback, useState } from 'react'
+import { Theme, useStyles } from '../../styles'
+import { Tooltip } from '../Tooltip'
+import { Button } from '../Button'
+import { Icon } from '../Icon'
+import { useLocale } from '../../i18n'
+
+export interface ActionableToastProps {
+  id: number
+  secondsVisible: number
+  title?: string
+  message: React.ReactNode
+  buttonLabel?: React.ReactNode
+  newToast?: boolean
+  action?(): void
+  onClose?(): void
+  removeToast(id: number): void
+}
+
+export function ActionableToast(props: ActionableToastProps) {
+  const {
+    id,
+    message,
+    onClose = () => {},
+    title,
+    buttonLabel = 'Button',
+    newToast,
+    removeToast,
+    action,
+    secondsVisible,
+  } = props
+
+  const locale = useLocale()
+  const timeoutRef = useRef<number>(null)
+  const { classes } = useStyles(createStyles)
+  const [isVisible, setIsVisible] = useState(false)
+
+  const handleCloseClick = () => {
+    onClose()
+    removeToast(id)
+  }
+
+  const clearTimer = () => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current)
+      timeoutRef.current = null
+    }
+  }
+
+  const startTimer = useCallback(() => {
+    timeoutRef.current = setTimeout(() => {
+      setIsVisible(false)
+      timeoutRef.current = setTimeout(() => {
+        removeToast(id)
+      }, 500)
+    }, secondsVisible * 1000)
+  }, [id, removeToast, secondsVisible])
+
+  useEffect(() => {
+    setIsVisible(true)
+    startTimer()
+    return () => clearTimer()
+  }, [secondsVisible, id, startTimer])
+
+  return (
+    <div
+      className={`${classes.container} ${isVisible ? 'visible' : 'hidden'}`}
+      onMouseEnter={clearTimer}
+      onMouseLeave={startTimer}
+    >
+      <div className={classes.headerWrapper}>
+        {!!newToast && <div aria-hidden='true' className={classes.marker} />}
+        <div className={classes.title}>
+          <h5>{title}</h5>
+        </div>
+        <span className={classes.closeButtonWrapper}>
+          <Tooltip text={locale.alert.close}>
+            <Button
+              aria-label={locale.alert.close}
+              size='small'
+              skin='ghost'
+              style={classes.closeButton}
+              onClick={handleCloseClick}
+            >
+              <Icon icon='timesDefault' />
+            </Button>
+          </Tooltip>
+        </span>
+      </div>
+      <div className={classes.toastMessage}>
+        <h5>{message}</h5>
+      </div>
+      {!!action && (
+        <div className={classes.actionButtonWrapper}>
+          <Button style={classes.actionButton} size='small' onClick={action}>
+            {buttonLabel}
+          </Button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+const createStyles = (theme: Theme) => ({
+  container: {
+    borderRadius: '2px',
+    border: '1px',
+    display: 'flex',
+    flexDirection: 'column',
+    position: 'relative',
+    marginTop: '0.25rem',
+    bottom: '1.25rem',
+    minHeight: '5rem',
+    minWidth: '16rem',
+    backgroundColor: 'white',
+    padding: '0 0.5rem 0.5rem 0',
+    boxShadow: theme.shadows.outer[40],
+    transition: 'transform 0.5s ease-in-out',
+    transform: 'translateX(-100%)',
+    [`&.visible`]: { transform: 'translateX(0%)' },
+    [`&.hidden`]: { transform: 'translateX(100%)' },
+  } as CSSProperties,
+  toastMessage: {
+    fontWeight: 'normal',
+    display: 'flex',
+    alignItems: 'top',
+    padding: '0 0 0.5rem 1rem',
+  } as CSSProperties,
+  closeButtonWrapper: {
+    position: 'absolute',
+    width: '1.5rem',
+    height: '1.5rem',
+    right: '1rem',
+    top: '0.5rem',
+  } as CSSProperties,
+  closeButton: {
+    padding: 0,
+  } as CSSProperties,
+  actionButtonWrapper: {
+    display: 'flex',
+    justifyContent: 'flex-end',
+  } as CSSProperties,
+  actionButton: {
+    border: `1 px solid ${theme.pallete.gray.c40}`,
+    borderRadius: '0.25rem',
+  } as CSSProperties,
+  headerWrapper: {
+    display: 'flex',
+    justifyContent: 'flex-start',
+    alignItems: 'center',
+    padding: '0 0 0.25rem 1rem',
+    marginTop: '1rem',
+  } as CSSProperties,
+  marker: {
+    borderRadius: '50%',
+    height: '0.5rem',
+    width: '0.5rem',
+    backgroundColor: theme.pallete.status.danger.c40,
+    marginRight: '0.5rem',
+  } as CSSProperties,
+  title: {
+    color: theme.pallete.primary.main,
+  } as CSSProperties,
+})
diff --git a/src/components/ActionableToast/ActionableToastList.tsx b/src/components/ActionableToast/ActionableToastList.tsx
new file mode 100644
index 000000000..6b4ec9c7c
--- /dev/null
+++ b/src/components/ActionableToast/ActionableToastList.tsx
@@ -0,0 +1,50 @@
+import React, { CSSProperties } from 'react'
+import { useStyles } from '../../styles'
+import { ActionableToast, ActionableToastProps } from './ActionableToast'
+
+export interface ActionableToastListProps {
+  marginTop?: number
+  marginRight?: string
+  data: ActionableToastProps[]
+}
+
+export function ActionableToastList(props: ActionableToastListProps) {
+  const { data, marginTop = 1, marginRight = '1.5rem' } = props
+  const { classes } = useStyles(() => createStyles(marginTop, marginRight))
+
+  return (
+    data.length > 0 && (
+      <div className={classes.container}>
+        {data.map((toast: ActionableToastProps) => (
+          <ActionableToast
+            key={toast.id}
+            id={toast.id}
+            message={toast.message}
+            title={toast.title}
+            buttonLabel={toast.buttonLabel}
+            onClose={toast.onClose}
+            newToast={toast.newToast}
+            action={toast.action}
+            removeToast={toast.removeToast}
+            secondsVisible={toast.secondsVisible}
+          />
+        ))}
+      </div>
+    )
+  )
+}
+
+const createStyles = (marginTop: number, marginRight: string) => ({
+  container: {
+    position: 'fixed',
+    padding: '0',
+    paddingTop: '1rem',
+    width: '100%',
+    maxWidth: '17rem',
+    maxHeight: '100vh',
+    overflow: 'hidden',
+    top: `${marginTop}rem`,
+    right: marginRight,
+    zIndex: 20,
+  } as CSSProperties,
+})
diff --git a/src/components/ActionableToast/index.ts b/src/components/ActionableToast/index.ts
new file mode 100644
index 000000000..e938ec934
--- /dev/null
+++ b/src/components/ActionableToast/index.ts
@@ -0,0 +1,3 @@
+export { ActionableToast, ActionableToastProps } from './ActionableToast'
+
+export { ActionableToastList, ActionableToastListProps } from './ActionableToastList'
diff --git a/src/components/index.ts b/src/components/index.ts
index 2e3b38804..3ecf948d4 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,3 +1,4 @@
+export * from './ActionableToast'
 export * from './Alert'
 export * from './Breadcrumb'
 export * from './Button'
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 35cd9b30d..5f52de30e 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -4,5 +4,6 @@ export * from './useFormControl'
 export * from './useIsMounted'
 export * from './useRovingTabIndex'
 export * from './useScrollPosition'
+export * from './useToastMessages'
 export * from './useTransition'
 export * from './useWindowSize'
diff --git a/src/hooks/useToastMessages.ts b/src/hooks/useToastMessages.ts
new file mode 100644
index 000000000..564eae31b
--- /dev/null
+++ b/src/hooks/useToastMessages.ts
@@ -0,0 +1,43 @@
+import { Dispatch, SetStateAction, useState } from 'react'
+import { ActionableToastProps } from '../components/ActionableToast'
+
+export interface ToastMessagesInterface {
+  secondsVisible?: number
+  newToast?: boolean
+  title: string
+  message: React.ReactNode
+  buttonLabel?: React.ReactNode
+  action?: () => void
+  onClose?: () => void
+}
+
+export function useToastMessages(): {
+  toastMessages: ActionableToastProps[]
+  setToastMessages: Dispatch<SetStateAction<ActionableToastProps[]>>
+  showToast: (variables: ToastMessagesInterface) => void
+} {
+  const [toastMessages, setToastMessages] = useState<ActionableToastProps[]>([])
+  const removeToast = (id: number) => {
+    setToastMessages((prevToasts) => prevToasts.filter((toast) => toast.id !== id))
+  }
+
+  const showToast = (props: ToastMessagesInterface) => {
+    const { message, newToast = false, title, buttonLabel, action, secondsVisible = 5, onClose } = props
+
+    const toast = {
+      id: Date.now(),
+      message: message,
+      action: action,
+      title: title,
+      newToast: newToast,
+      buttonLabel: buttonLabel,
+      removeToast,
+      secondsVisible,
+      onClose,
+    }
+
+    setToastMessages((prevToasts) => [...prevToasts, toast])
+  }
+
+  return { toastMessages, setToastMessages, showToast }
+}


Test patch:
diff --git a/src/__snapshots__/index.test.ts.snap b/src/__snapshots__/index.test.ts.snap
index 1c37e73fd..0835f4761 100644
--- a/src/__snapshots__/index.test.ts.snap
+++ b/src/__snapshots__/index.test.ts.snap
@@ -2,6 +2,8 @@
 
 exports[`should export components to global namespace 1`] = `
 Array [
+  "ActionableToast",
+  "ActionableToastList",
   "Alert",
   "Breadcrumbs",
   "ButtonBase",