diff --git a/lib/components/LayoutAction/ActionFooter.stories.tsx b/lib/components/LayoutAction/ActionFooter.stories.tsx index 051da75e..54750c79 100644 --- a/lib/components/LayoutAction/ActionFooter.stories.tsx +++ b/lib/components/LayoutAction/ActionFooter.stories.tsx @@ -1,6 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react'; - -import { Snackbar } from '../Snackbar'; import { ActionFooter } from './ActionFooter'; import { ActionMenu, type ActionMenuProps } from './ActionMenu'; @@ -45,26 +43,3 @@ export const Menu: Story = { children: , }, }; - -export const Snackbars: Story = { - args: { - children: ( - <> - - - - - ), - }, -}; - -export const MenuAndSnackbar: Story = { - args: { - children: ( - <> - - - - ), - }, -}; diff --git a/lib/components/RootProvider/RootProvider.tsx b/lib/components/RootProvider/RootProvider.tsx index 496d6007..f47af9be 100644 --- a/lib/components/RootProvider/RootProvider.tsx +++ b/lib/components/RootProvider/RootProvider.tsx @@ -1,6 +1,7 @@ 'use client'; import { type ReactNode, createContext, useContext, useState } from 'react'; import { useEscapeKey } from '../../hooks'; +import { SnackbarProvider } from '../Snackbar/useSnackbar.tsx'; type OpenElementId = 'search' | 'menu' | string; @@ -36,7 +37,7 @@ export const RootProvider = ({ children, initialValue }: ProviderProps) => { setCurrentId, }} > - {children} + {children} ); }; diff --git a/lib/components/Snackbar/Snackbar.stories.tsx b/lib/components/Snackbar/Snackbar.stories.tsx index b5bce294..5d31146c 100644 --- a/lib/components/Snackbar/Snackbar.stories.tsx +++ b/lib/components/Snackbar/Snackbar.stories.tsx @@ -1,20 +1,63 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { Snackbar } from './Snackbar'; +import type { Meta } from '@storybook/react'; +import { Button } from '../Button'; +import { Flex } from '../Page'; +import { Snackbar } from './Snackbar.tsx'; +import { useSnackbar } from './useSnackbar.tsx'; const meta = { title: 'Snackbar/Snackbar', component: Snackbar, - tags: ['autodocs'], + tags: ['autodocs', 'beta'], parameters: {}, - args: { - icon: 'bell', - message: 'Message', - }, + args: {}, } satisfies Meta; export default meta; -type Story = StoryObj; -export const Default: Story = { - args: {}, +export const Default = () => { + const { openSnackbar } = useSnackbar(); + + return ( +
+ + + + + + +
+ ); }; diff --git a/lib/components/Snackbar/Snackbar.tsx b/lib/components/Snackbar/Snackbar.tsx index 6fc23f7f..29a63922 100644 --- a/lib/components/Snackbar/Snackbar.tsx +++ b/lib/components/Snackbar/Snackbar.tsx @@ -1,31 +1,30 @@ -import type { ElementType } from 'react'; -import type { IconName } from '../Icon'; -import { SnackbarBase, type SnackbarColor } from './SnackbarBase'; -import { SnackbarLabel } from './SnackbarLabel'; -import { SnackbarMedia } from './SnackbarMedia'; +'use client'; +import { SnackbarBase } from './SnackbarBase.tsx'; +import { SnackbarItem } from './SnackbarItem'; +import { useSnackbar } from './useSnackbar'; export interface SnackbarProps { - /** Element type to render */ - as?: ElementType; - /** Color */ - color?: SnackbarColor; - /** Message */ - message?: string; - /** Icon */ - icon?: IconName; - /** Dismissable */ - dismissable?: boolean; - /** onDismiss */ - onDismiss?: () => void; /** Optional classname */ className?: string; } -export const Snackbar = ({ as = 'a', color, message, icon, ...rest }: SnackbarProps) => { +export const Snackbar = ({ className }: SnackbarProps) => { + const { storedMessages, open, closeSnackbarItem } = useSnackbar(); + + if (!open) { + return null; + } + return ( - - - {message} + + {(storedMessages || []).map((item) => ( + closeSnackbarItem(item.id)} + dismissable={item.dismissable} + {...item} + /> + ))} ); }; diff --git a/lib/components/Snackbar/SnackbarBase.tsx b/lib/components/Snackbar/SnackbarBase.tsx index 159d0f95..266c7d45 100644 --- a/lib/components/Snackbar/SnackbarBase.tsx +++ b/lib/components/Snackbar/SnackbarBase.tsx @@ -1,39 +1,17 @@ -import cx from 'classnames'; -import type { ElementType, ReactNode } from 'react'; -import { IconButton } from '../Button'; +import type { ReactNode } from 'react'; import styles from './snackbarBase.module.css'; -export type SnackbarColor = 'default' | 'accent'; - -interface SnackbarBaseProps { - as?: ElementType; - color?: SnackbarColor; - href?: string; +export interface SnackbarBaseProps { + /** Optional classname */ className?: string; - dismissable?: boolean; - onDismiss?: () => void; - children?: ReactNode; + /** Children */ + children: string | ReactNode; } -export const SnackbarBase = ({ - as, - children, - className, - color, - dismissable = true, - onDismiss, - ...rest -}: SnackbarBaseProps) => { - const Component = as || 'div'; - +export const SnackbarBase = ({ children }: SnackbarBaseProps) => { return ( - -
{children}
- {dismissable && ( -
- -
- )} -
+
+ {children} +
); }; diff --git a/lib/components/Snackbar/SnackbarItem.tsx b/lib/components/Snackbar/SnackbarItem.tsx new file mode 100644 index 00000000..5609d285 --- /dev/null +++ b/lib/components/Snackbar/SnackbarItem.tsx @@ -0,0 +1,45 @@ +import cx from 'classnames'; +import type { ElementType, ReactNode } from 'react'; +import { Icon, IconButton, type IconName } from '..'; +import styles from './snackbarItem.module.css'; + +export type SnackbarColor = 'default' | 'accent' | 'alert'; + +interface SnackbarItemProps { + as?: ElementType; + color?: SnackbarColor; + icon?: IconName; + message: string | ReactNode; + href?: string; + className?: string; + dismissable?: boolean; + onDismiss?: () => void; + children?: ReactNode; +} + +export const SnackbarItem = ({ + as, + message, + className, + color, + icon = 'bell', + dismissable = true, + onDismiss, + ...rest +}: SnackbarItemProps) => { + const Component = as || 'div'; + + return ( + +
+ +
+
{message}
+ {dismissable && ( +
+ +
+ )} +
+ ); +}; diff --git a/lib/components/Snackbar/SnackbarLabel.tsx b/lib/components/Snackbar/SnackbarLabel.tsx deleted file mode 100644 index dd068b31..00000000 --- a/lib/components/Snackbar/SnackbarLabel.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from 'react'; -import styles from './snackbarLabel.module.css'; - -export interface SnackbarLabelProps { - children?: ReactNode; -} - -export const SnackbarLabel = ({ children }: SnackbarLabelProps) => { - return {children}; -}; diff --git a/lib/components/Snackbar/SnackbarMedia.tsx b/lib/components/Snackbar/SnackbarMedia.tsx deleted file mode 100644 index 4504a2fa..00000000 --- a/lib/components/Snackbar/SnackbarMedia.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Icon, type IconName } from '../Icon'; -import styles from './snackbarMedia.module.css'; - -interface SnackbarMediaProps { - icon?: IconName; -} - -export const SnackbarMedia = ({ icon = 'bell' }: SnackbarMediaProps) => { - return ( -
- -
- ); -}; diff --git a/lib/components/Snackbar/index.ts b/lib/components/Snackbar/index.ts index 1ca8d9ec..98935a0b 100644 --- a/lib/components/Snackbar/index.ts +++ b/lib/components/Snackbar/index.ts @@ -1,4 +1,4 @@ -export * from './Snackbar'; -export * from './SnackbarBase'; -export * from './SnackbarMedia'; -export * from './SnackbarLabel'; +export * from './Snackbar.tsx'; +export * from './SnackbarItem'; +//export * from './SnackbarMedia'; +//export * from './SnackbarLabel'; diff --git a/lib/components/Snackbar/snackbarBase.module.css b/lib/components/Snackbar/snackbarBase.module.css index 17798857..607809eb 100644 --- a/lib/components/Snackbar/snackbarBase.module.css +++ b/lib/components/Snackbar/snackbarBase.module.css @@ -1,55 +1,17 @@ -.item { - border: none; - - color: inherit; - font: inherit; - text-align: inherit; - - line-height: normal; - - -webkit-font-smoothing: inherit; - -moz-osx-font-smoothing: inherit; - - user-select: none; - - border-radius: 0.5rem; -} - -.item { - position: relative; - display: flex; - align-items: center; - text-decoration: none; - color: inherit; -} - -.content { - flex-grow: 1; +.stack { + position: fixed; + top: auto; + right: 0; + bottom: 0; + left: 0; display: flex; - align-items: center; - gap: 0.625rem; - margin: 0.625rem; -} - -.action { - flex-shrink: 0; - display: flex; - align-items: center; - margin: 0.625rem; -} - -/* colors */ - -.item { - background-color: var(--global-base-default); - color: #fff; - box-shadow: var(--ds-shadow-xs); -} - -.item[data-color="accent"] { - background-color: var(--theme-surface-default); + flex-direction: column; + row-gap: 0.5rem; + padding: 1rem; } -.item[data-color="accent"]:hover { - background-color: var(--theme-surface-hover); +@media (min-width: 1024px) { + .stack { + align-items: center; + } } diff --git a/lib/components/Snackbar/snackbarItem.module.css b/lib/components/Snackbar/snackbarItem.module.css new file mode 100644 index 00000000..50534331 --- /dev/null +++ b/lib/components/Snackbar/snackbarItem.module.css @@ -0,0 +1,70 @@ +.item { + border: none; + color: inherit; + font: inherit; + text-align: inherit; + line-height: normal; + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + user-select: none; + border-radius: 0.5rem; + box-shadow: var(--ds-shadow-xs); +} + +.item { + position: relative; + display: flex; + align-items: center; + text-decoration: none; + color: inherit; + padding: 0.625rem; +} + +.media { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; +} + +.icon { + font-size: 1.5rem; +} + +.content { + flex-grow: 1; + display: flex; + align-items: center; + margin-left: 0.625rem; + margin-right: 1.25rem; +} + +.action { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* colors */ + +.item { + background-color: var(--global-base-default); + color: white; +} + +.item[data-color="accent"] { + background-color: var(--global-base-default); + color: white; +} + +.item[data-color="accent"]:hover { + background-color: var(--global-base-hover); +} + +.item[data-color="alert"] { + background-color: #e02e49; + color: white; +} diff --git a/lib/components/Snackbar/snackbarLabel.module.css b/lib/components/Snackbar/snackbarLabel.module.css deleted file mode 100644 index 4a40e62f..00000000 --- a/lib/components/Snackbar/snackbarLabel.module.css +++ /dev/null @@ -1,6 +0,0 @@ -/* label */ - -.label { - display: flex; - justify-content: center; -} diff --git a/lib/components/Snackbar/snackbarMedia.module.css b/lib/components/Snackbar/snackbarMedia.module.css deleted file mode 100644 index 8e9a2530..00000000 --- a/lib/components/Snackbar/snackbarMedia.module.css +++ /dev/null @@ -1,10 +0,0 @@ -.media { - display: flex; - align-items: center; - justify-content: center; -} - -.icon { - font-size: 1.5rem; - margin: 0.625rem; -} diff --git a/lib/components/Snackbar/useSnackbar.tsx b/lib/components/Snackbar/useSnackbar.tsx new file mode 100644 index 00000000..1e56bc67 --- /dev/null +++ b/lib/components/Snackbar/useSnackbar.tsx @@ -0,0 +1,118 @@ +'use client'; +import { + type ElementType, + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import type { IconName } from '../Icon'; +import type { SnackbarColor } from './SnackbarItem.tsx'; + +export enum SnackbarDuration { + infinite = 0, + short = 1000, + normal = 3000, + long = 5000, +} + +export interface OpenSnackbarInput extends Omit {} + +export interface SnackbarQueueItem extends SnackbarItemProps { + id: string; + message: string; + color: SnackbarColor; + duration?: number; + icon?: IconName; +} + +interface SnackbarItemProps { + as?: ElementType; + color?: SnackbarColor; + href?: string; + className?: string; + dismissable?: boolean; + onDismiss?: () => void; + children?: ReactNode; +} + +interface SnackbarContextValue { + open: boolean; + storedMessages: SnackbarQueueItem[]; + openSnackbar: (input: OpenSnackbarInput) => string; + closeSnackbarItem: (id: string) => void; + dismissSnackbar: () => void; +} + +const SnackbarContext = createContext(undefined); +const defaultDuration = SnackbarDuration.normal; + +export const SnackbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + const [storedMessages, setStoredMessages] = useState([]); + const closingTime = useRef(null); + + const dismissSnackbar = () => { + setStoredMessages([]); + setIsOpen(false); + if (closingTime.current) { + clearTimeout(closingTime.current); + } + }; + + const openSnackbar = (message: OpenSnackbarInput): string => { + const id = btoa(String(Math.random())).substring(0, 12); + setStoredMessages((prevMessages) => [...prevMessages, { ...message, id }]); + setIsOpen(true); + return id; + }; + + const closeSnackbarItem = useCallback((id: string) => { + setStoredMessages((prevMessages) => { + const updatedMessages = prevMessages.filter((item) => item.id !== id); + setIsOpen(updatedMessages.length > 0); + return updatedMessages; + }); + }, []); + + useEffect(() => { + const activeMessage = storedMessages.find((item) => (item.duration ?? defaultDuration) > 0); + const duration = activeMessage?.duration || defaultDuration; + if (activeMessage) { + closingTime.current = setTimeout(() => { + closeSnackbarItem(activeMessage.id); + }, duration); + } + + return () => { + if (closingTime.current) { + clearTimeout(closingTime.current); + } + }; + }, [storedMessages, closeSnackbarItem]); + + return ( + + {children} + + ); +}; + +export const useSnackbar = (): SnackbarContextValue => { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error('useSnackbar must be used within a SnackbarProvider'); + } + return context; +};