Skip to content

Commit

Permalink
Merge pull request #6 from Rue-pro/ui-toast-component
Browse files Browse the repository at this point in the history
UI toast component
  • Loading branch information
Rue-pro authored Mar 15, 2024
2 parents 4527b3f + 5d6716a commit ae2f24e
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 2 deletions.
3 changes: 3 additions & 0 deletions public/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"extDescription": {
"message": "Chrome extension for filling anki cards automatically",
"description": "Extension description"
},
"close": {
"message": "Close"
}
}
8 changes: 6 additions & 2 deletions src/shared/ui/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { ComponentChild } from 'preact'

import styles from './styles.module.scss'

interface Props extends JSXInternal.HTMLAttributes<HTMLButtonElement> {
export type TButtonColor = 'accent' | 'success' | 'error' | 'info'

export interface Props extends JSXInternal.HTMLAttributes<HTMLButtonElement> {
startIcon?: ComponentChild
endIcon?: ComponentChild
variant?: 'primary' | 'secondary'
color?: 'accent' | 'success' | 'error' | 'info'
color?: TButtonColor
}

export const Button = ({
Expand All @@ -17,12 +19,14 @@ export const Button = ({
endIcon,
variant = 'primary',
color = 'accent',
className,
...rest
}: Props) => {
const buttonClass = cn(
styles.button,
styles[`button--variant-${variant}`],
styles[`button--color-${color}`],
className ?? '',
)

return (
Expand Down
63 changes: 63 additions & 0 deletions src/shared/ui/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import cn from 'classnames'
import { ComponentChildren } from 'preact'

import { browser } from '@shared/browser'

import { Button, TButtonColor } from '../Button'
import { ClearIcon } from '../icons/ClearIcon'
import styles from './styles.module.scss'

export type TToastType = 'error' | 'info' | 'success'
export interface IToast {
title: string
message?: ComponentChildren
details?: ComponentChildren
id: string
type: TToastType
}

interface Props {
toast: IToast
removeToast: () => void
openDetails: (details: ComponentChildren) => void
}

const mapToastTypeToButtonColor: Record<TToastType, TButtonColor> = {
error: 'error',
info: 'info',
success: 'success',
}

export const Toast = ({ toast, removeToast, openDetails }: Props) => {
const { title, message, details, type } = toast

const toastClass = cn(styles.toast, styles[`toast--variant-${type}`])

return (
<li className={toastClass} role="status">
<div className={styles.toast__content}>
<p className={cn(styles.toast__title, 'h2')}>{title}</p>

{typeof message === 'string' ? <p>{message}</p> : message}

{!!details && (
<Button
variant="secondary"
color={mapToastTypeToButtonColor[type]}
onClick={() => openDetails(details)}
>
Open details
</Button>
)}
</div>

<Button
variant="secondary"
color={mapToastTypeToButtonColor[type]}
endIcon={<ClearIcon />}
onClick={removeToast}
aria-label={browser.i18n.getMessage('closeToast')}
/>
</li>
)
}
47 changes: 47 additions & 0 deletions src/shared/ui/Toast/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ComponentChildren, createContext } from 'preact'
import { useContext, useState } from 'preact/hooks'

import { Modal } from '../Modal'
import { Toast } from './Toast'
import styles from './styles.module.scss'
import { IAddToastProps, useToastsStore } from './toastStore'

const ToastDispatchContext = createContext<{
addToast: (toast: IAddToastProps) => void
}>({
addToast: () => {
throw Error('Not implemented')
},
})

export const ToastProvider = ({
children,
}: {
children: ComponentChildren
}) => {
const { toasts, addToast, removeToast } = useToastsStore()
const [details, setDetails] = useState<ComponentChildren | null>(null)

return (
<ToastDispatchContext.Provider value={{ addToast }}>
<ul className={styles.toasts}>
{toasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
removeToast={() => removeToast(toast.id)}
openDetails={setDetails}
/>
))}
</ul>

{children}

<Modal open={!!details} onClose={() => setDetails(null)}>
{details}
</Modal>
</ToastDispatchContext.Provider>
)
}

export const useToast = () => useContext(ToastDispatchContext)
1 change: 1 addition & 0 deletions src/shared/ui/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ToastProvider, useToast } from './ToastContext'
53 changes: 53 additions & 0 deletions src/shared/ui/Toast/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
.toasts {
position: fixed;
bottom: 0;
left: 50%;
display: flex;
flex-direction: column;
gap: var(--spacing-1);
width: 100%;
transform: translate(-50%, 0);
}

.toast {
display: flex;
gap: var(--spacing-2);
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing-3);
color: var(--text);
background-color: var(--background);
border-radius: var(--radius-1);

&__content {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
align-items: flex-start;
}

&__title {
color: var(--title);
}

&--variant {
&-success {
--title: var(--color-success-900);
--text: var(--color-success-700);
--background: var(--color-success-100);
}

&-error {
--title: var(--color-error-900);
--text: var(--color-error-700);
--background: var(--color-error-100);
}

&-info {
--title: var(--color-info-900);
--text: var(--color-info-700);
--background: var(--color-info-100);
}
}
}
36 changes: 36 additions & 0 deletions src/shared/ui/Toast/toastStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useCallback, useRef, useState } from 'preact/hooks'

import { IToast } from './Toast'

export type IAddToastProps = Omit<IToast, 'id'>

export const useToastsStore = () => {
const timersRef = useRef<Record<string, NodeJS.Timeout>>({})
const [toasts, setToasts] = useState<IToast[]>([])

const addToast = useCallback((toast: IAddToastProps) => {
const toastId = new Date().toISOString()
setToasts((prevToasts) => [
{
id: toastId,
...toast,
},
...prevToasts,
])
autoCloseToast(toastId)
}, [])

const removeToast = useCallback((id: string) => {
setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id))
clearTimeout(timersRef.current[id])
delete timersRef.current[id]
}, [])

const autoCloseToast = (id: string) => {
timersRef.current[id] = setTimeout(() => {
removeToast(id)
}, 3000)
}

return { toasts, addToast, removeToast }
}
8 changes: 8 additions & 0 deletions src/shared/ui/icons/ClearIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const ClearIcon = () => (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
fill="currentColor"
></path>
</svg>
)

0 comments on commit ae2f24e

Please sign in to comment.