Skip to content

Commit

Permalink
feat(components): add alert and use it in snackbar (#112)
Browse files Browse the repository at this point in the history
  • Loading branch information
domhhv authored Oct 23, 2024
1 parent 458c355 commit b9a9c82
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 90 deletions.
91 changes: 91 additions & 0 deletions src/components/common/Alert/Alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Button, type ButtonProps } from '@nextui-org/react';
import {
BellRinging,
CheckCircle,
type Icon,
Info,
Question,
Warning,
WarningCircle,
} from '@phosphor-icons/react';
import clsx from 'clsx';
import React from 'react';
import { type SetRequired, type ValueOf } from 'type-fest';

export type ButtonColor = ValueOf<
SetRequired<Pick<ButtonProps, 'color'>, 'color'>
>;

export type AlertOptions = {
color?: ButtonColor;
autoHideDuration?: number;
dismissible?: boolean;
description?: string;
dismissText?: string;
onDismiss?: () => void;
testId?: string;
};

const ICONS_BY_COLOR: Record<ButtonColor, Icon> = {
secondary: Question,
default: Info,
success: CheckCircle,
warning: Warning,
danger: WarningCircle,
primary: BellRinging,
};

type AlertProps = AlertOptions & {
message: string;
};

const Alert = ({
message,
description,
dismissible,
onDismiss,
dismissText = 'Dismiss',
color = 'default',
testId = 'alert',
}: AlertProps) => {
const endDecorator = dismissible ? (
<Button onClick={onDismiss} size="sm" color={color} variant="flat">
{dismissText}
</Button>
) : null;

const alertClassName = clsx(
'flex items-center gap-4 rounded-md border px-4 py-2',
color === 'default' &&
'border-neutral-300 bg-slate-50 text-slate-700 dark:border-neutral-700 dark:bg-slate-900 dark:text-slate-100',
color === 'primary' &&
'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200',
color === 'secondary' &&
'border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-900 dark:text-purple-200',
color === 'success' &&
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900 dark:text-green-200',
color === 'warning' &&
'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-200',
color === 'danger' &&
'border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900 dark:text-red-200'
);

const Icon = ICONS_BY_COLOR[color];

return (
<div className={alertClassName} data-testid={testId}>
<Icon weight="bold" size={18} />
<div className="flex w-full items-center justify-between gap-8">
<div className="flex flex-col">
<h6 className="font-semibold" data-testid="snackbar-message">
{message}
</h6>
{description && <p className="text-sm">{description}</p>}
</div>
{endDecorator}
</div>
</div>
);
};

export default Alert;
2 changes: 2 additions & 0 deletions src/components/common/Alert/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Alert';
export { default as Alert } from './Alert';
1 change: 1 addition & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ConfirmDialog';
export * from './VisuallyHiddenInput';
export * from './PasswordInput';
export * from './Alert';
21 changes: 3 additions & 18 deletions src/context/Snackbar/SnackbarContext.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,14 @@
import { type AlertOptions } from '@components';
import React from 'react';

export type ButtonColor =
| 'default'
| 'secondary'
| 'primary'
| 'success'
| 'warning'
| 'danger';

export type SnackbarOptions = {
color?: ButtonColor;
autoHideDuration?: number;
dismissible?: boolean;
description?: string;
dismissText?: string;
};

export type Snackbar = {
id: string;
message: string;
options: SnackbarOptions;
options: AlertOptions;
};

type SnackbarContextType = {
showSnackbar: (message: string, options?: SnackbarOptions) => void;
showSnackbar: (message: string, options?: AlertOptions) => void;
};

export const SnackbarContext = React.createContext<SnackbarContextType | null>(
Expand Down
82 changes: 10 additions & 72 deletions src/context/Snackbar/SnackbarProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import {
SnackbarContext,
type SnackbarOptions,
type Snackbar,
type ButtonColor,
} from '@context';
import { Button } from '@nextui-org/react';
import {
BellRinging,
CheckCircle,
type Icon,
Info,
Question,
Warning,
WarningCircle,
} from '@phosphor-icons/react';
import clsx from 'clsx';
import { type AlertOptions, Alert } from '@components';
import { SnackbarContext, type Snackbar } from '@context';
import React, { type ReactNode } from 'react';

const SnackbarProvider = ({ children }: { children: ReactNode }) => {
const [snackbars, setSnackbars] = React.useState<Snackbar[]>([]);

const showSnackbar = React.useCallback(
(message: string, options: SnackbarOptions = {}) => {
(message: string, options: AlertOptions = {}) => {
const id = crypto.randomUUID?.() || +new Date();

setSnackbars((prevSnackbars) => [
Expand All @@ -38,72 +23,25 @@ const SnackbarProvider = ({ children }: { children: ReactNode }) => {
);
};

const ICONS_BY_COLOR: Record<ButtonColor, Icon> = {
secondary: Question,
default: Info,
success: CheckCircle,
warning: Warning,
danger: WarningCircle,
primary: BellRinging,
};

const providerValue = React.useMemo(() => ({ showSnackbar }), [showSnackbar]);

return (
<SnackbarContext.Provider value={providerValue}>
{children}
<div className="fixed bottom-2 left-2 z-[99] flex flex-col gap-2">
{snackbars.map(({ id, message, options }) => {
const color = options.color || 'default';

const endDecorator = options.dismissible ? (
<Button
onClick={() => hideSnackbar(id)}
size="sm"
color={color}
variant="flat"
>
{options.dismissText || 'Dismiss'}
</Button>
) : null;

setTimeout(() => {
hideSnackbar(id);
}, options.autoHideDuration || 5000);

const snackbarClassName = clsx(
'flex items-center gap-4 rounded-md border px-4 py-2',
color === 'default' &&
'border-neutral-300 bg-slate-50 text-slate-700 dark:border-neutral-700 dark:bg-slate-900 dark:text-slate-100',
color === 'primary' &&
'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200',
color === 'secondary' &&
'border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-900 dark:text-purple-200',
color === 'success' &&
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900 dark:text-green-200',
color === 'warning' &&
'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-200',
color === 'danger' &&
'border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900 dark:text-red-200'
);

const Icon = ICONS_BY_COLOR[color];

return (
<div key={id} className={snackbarClassName} data-testid="snackbar">
<Icon weight="bold" size={18} />
<div className="flex w-full items-center justify-between gap-8">
<div className="flex flex-col">
<h6 className="font-semibold" data-testid="snackbar-message">
{message}
</h6>
{options.description && (
<p className="text-sm">{options.description}</p>
)}
</div>
{endDecorator}
</div>
</div>
<Alert
key={id}
message={message}
{...options}
onDismiss={() => hideSnackbar(id)}
testId="snackbar"
/>
);
})}
</div>
Expand Down

0 comments on commit b9a9c82

Please sign in to comment.