diff --git a/src/components/atoms/alert/Alert.stories.tsx b/src/components/atoms/alert/Alert.stories.tsx new file mode 100644 index 00000000..5453ad88 --- /dev/null +++ b/src/components/atoms/alert/Alert.stories.tsx @@ -0,0 +1,231 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Button } from '../button'; +import { Icon } from '../icon'; +import { Alert } from './Alert'; + +/** + * ## Description + * Alert renders assertive inline feedback for important status, warning, success, or error messaging. + * + * ## Dependencies + * Uses the design-system `Icon` component for the default leading indicator and close affordance. + * + * ## Usage Guide + * Use Alert for inline messages that should be announced immediately with `role="alert"`. Provide at least one of `title`, `subtitle`, or `children`. Use `dismissible` only when the user should be able to remove the message, and prefer `endContent` for secondary actions or metadata. + */ +const meta: Meta = { + title: 'Atoms/Alert', + component: Alert, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +/** + * Shows the default informational alert using the approved default variants. + */ +export const Default: Story = { + args: { + title: 'Connection restored', + subtitle: 'Everything is back online and syncing normally.' + } +}; + +/** + * Shows the uncontrolled dismissible behavior with the built-in close button. + */ +export const DismissibleUncontrolled: Story = { + args: { + title: 'Updates available', + subtitle: 'Install the latest release to access new fixes and components.', + dismissible: true, + onOpenChange: action('open-change') + } +}; + +/** + * Shows a controlled dismissible alert that can be reopened by the parent. + */ +export const DismissibleControlled: Story = { + render: (args) => { + const [open, setOpen] = useState(true); + + return ( +
+
+ ); + }, + args: { + title: 'Billing reminder', + subtitle: 'Your subscription renews in three days.', + dismissible: true + } +}; + +/** + * Shows the solid alert variant across semantic colors. + */ +export const VariantSolid: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +/** + * Shows the bordered alert variant across semantic colors. + */ +export const VariantBordered: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +/** + * Shows the flat alert variant across semantic colors. + */ +export const VariantFlat: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +/** + * Shows a custom token-backed color treatment through className and startContent. + */ +export const CustomColor: Story = { + args: { + title: 'Informational note', + subtitle: 'Use className and custom startContent when the alert needs a non-standard semantic color.', + className: 'border-info-light bg-info-tint dark:border-info dark:bg-info-tint', + startContent: ( + + ) + } +}; + +/** + * Shows the title-only layout with the close button aligned to the title row. + */ +export const TitleOnlyDismissible: Story = { + args: { + title: 'Title-only alert', + dismissible: true, + onOpenChange: action('open-change') + } +}; + +/** + * Shows custom slot content overriding the default icon and adding a trailing action. + */ +export const CustomStartAndEndContent: Story = { + args: { + title: 'Deployment paused', + subtitle: 'A reviewer must approve the release before it can continue.', + startContent: , + endContent: ); + const beforeButton = screen.getByRole('button', { name: 'Before' }); + + beforeButton.focus(); + expect(beforeButton).toHaveFocus(); + + rerender( + <> + + + + ); + + expect(beforeButton).toHaveFocus(); + expect(screen.getByRole('alert')).not.toHaveAttribute('tabindex'); + }); + + it('does not relocate focus after dismissing the alert', async () => { + vi.useFakeTimers(); + + render( + <> + + + + + ); + + const closeButton = screen.getByRole('button', { name: 'Dismiss alert' }); + const afterButton = screen.getByRole('button', { name: 'After' }); + closeButton.focus(); + + fireEvent.click(closeButton); + + act(() => { + vi.advanceTimersByTime(250); + }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(afterButton).not.toHaveFocus(); + }); + + it('removes the alert immediately when reduced motion is preferred', async () => { + setReducedMotion(true); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Dismiss alert' })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('reads reduced motion preference during initial render before effects can run', () => { + setReducedMotion(true); + + renderToString(); + + expect(window.matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + }); + + it('renders valid falsy ReactNode content without breaking aria references', () => { + render( + + {0} + + ); + + const alert = screen.getByRole('alert'); + const labelledBy = alert.getAttribute('aria-labelledby'); + const describedBy = alert.getAttribute('aria-describedby'); + + expect(document.getElementById(labelledBy ?? '')).toHaveTextContent('0'); + expect(document.getElementById(describedBy ?? '')).toHaveTextContent('00'); + expect(alert).toHaveTextContent('0000'); + }); + + it('renders null when title, subtitle, and children are all missing', () => { + render(Action} startContent={Icon} />); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/alert/Alert.tsx b/src/components/atoms/alert/Alert.tsx new file mode 100644 index 00000000..6544ef5a --- /dev/null +++ b/src/components/atoms/alert/Alert.tsx @@ -0,0 +1,98 @@ +import type { FC } from 'react'; +import { Icon } from '../icon'; +import type { AlertProps } from './types'; +import { useAlert } from './useAlert'; + +export const Alert: FC = (props) => { + const { + shouldRender, + dismissible, + closeButtonAriaLabel, + rootClassName, + innerClassName, + contentClassName, + startContentClassName, + iconClassName, + textContentClassName, + titleClassName, + descriptionClassName, + subtitleClassName, + bodyClassName, + endContentClassName, + closeButtonClassName, + titleId, + descriptionId, + hasTitle, + hasSubtitle, + hasBody, + hasStartContent, + hasEndContent, + showDefaultIcon, + resolvedIconName, + handleDismiss, + handleCloseKeyDown, + rootProps, + pieces + } = useAlert(props); + + if (!shouldRender) { + return null; + } + + return ( +
+
+
+ {(hasStartContent || showDefaultIcon) && ( +
+ {hasStartContent ? ( + pieces.startContent + ) : ( + + )} +
+ )} + +
+ {hasTitle && ( +
+ {pieces.title} +
+ )} + + {(hasSubtitle || hasBody) && ( +
+ {hasSubtitle &&
{pieces.subtitle}
} + {hasBody &&
{pieces.children}
} +
+ )} +
+ + {(hasEndContent || dismissible) && ( +
+ {hasEndContent && pieces.endContent} + {dismissible && ( + + )} +
+ )} +
+
+
+ ); +}; diff --git a/src/components/atoms/alert/index.ts b/src/components/atoms/alert/index.ts new file mode 100644 index 00000000..77875b61 --- /dev/null +++ b/src/components/atoms/alert/index.ts @@ -0,0 +1,2 @@ +export { Alert } from './Alert'; +export type * from './types'; diff --git a/src/components/atoms/alert/types.ts b/src/components/atoms/alert/types.ts new file mode 100644 index 00000000..22909472 --- /dev/null +++ b/src/components/atoms/alert/types.ts @@ -0,0 +1,166 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps, ReactNode } from 'react'; +import type { DynamicIconName } from '@/types'; + +export const alertVariants = cva( + [ + 'relative grid w-full overflow-hidden border', + 'text-left transition-[grid-template-rows,opacity,background-color,border-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none' + ], + { + variants: { + variant: { + solid: 'shadow-none', + bordered: 'bg-transparent', + flat: 'border-transparent' + }, + color: { + primary: '', + success: '', + warning: '', + danger: '' + }, + rounded: { + true: 'rounded-lg', + false: 'rounded-none' + } + }, + compoundVariants: [ + { + color: 'primary', + variant: 'solid', + class: + 'border-border-strong-light bg-surface-raised-light text-text-light dark:border-border-strong-dark dark:bg-surface-raised-dark dark:text-text-dark' + }, + { + color: 'primary', + variant: 'flat', + class: 'bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark' + }, + { + color: 'primary', + variant: 'bordered', + class: 'border-border-strong-light text-text-light dark:border-border-strong-dark dark:text-text-dark' + }, + { + color: 'success', + variant: 'solid', + class: 'border-success-light bg-success-tint text-text-light dark:border-success dark:text-text-dark' + }, + { + color: 'success', + variant: 'flat', + class: 'bg-success-tint text-text-light dark:text-text-dark' + }, + { + color: 'success', + variant: 'bordered', + class: 'border-success-light text-text-light dark:border-success dark:text-text-dark' + }, + { + color: 'warning', + variant: 'solid', + class: 'border-warning-light bg-warning-tint text-text-light dark:border-warning dark:text-text-dark' + }, + { + color: 'warning', + variant: 'flat', + class: 'bg-warning-tint text-text-light dark:text-text-dark' + }, + { + color: 'warning', + variant: 'bordered', + class: 'border-warning-light text-text-light dark:border-warning dark:text-text-dark' + }, + { + color: 'danger', + variant: 'solid', + class: 'border-error-light bg-error-tint text-text-light dark:border-error dark:text-text-dark' + }, + { + color: 'danger', + variant: 'flat', + class: 'bg-error-tint text-text-light dark:text-text-dark' + }, + { + color: 'danger', + variant: 'bordered', + class: 'border-error-light text-text-light dark:border-error dark:text-text-dark' + } + ], + defaultVariants: { + variant: 'solid', + color: 'primary', + rounded: true + } + } +); + +export type AlertVariant = NonNullable['variant']>; +export type AlertColor = NonNullable['color']>; + +type NativeAlertProps = Omit< + ComponentProps<'div'>, + 'aria-describedby' | 'aria-labelledby' | 'children' | 'className' | 'role' | 'title' +>; + +export const defaultAlertIcons: Record = { + primary: 'info', + success: 'circle-check', + warning: 'triangle-alert', + danger: 'circle-x' +}; + +export type AlertProps = NativeAlertProps & { + /** @control object */ + title?: ReactNode; + /** @control object */ + subtitle?: ReactNode; + /** @control object */ + children?: ReactNode; + /** + * @control select + * @default solid + */ + variant?: AlertVariant; + /** + * @control select + * @default primary + */ + color?: AlertColor; + /** + * @control boolean + * @default true + */ + rounded?: boolean; + /** + * @control boolean + * @default false + */ + dismissible?: boolean; + /** + * @control boolean + * @default true + */ + defaultOpen?: boolean; + /** @control boolean */ + open?: boolean; + /** @control object */ + onOpenChange?: (open: boolean) => void; + /** + * @control text + * @default Dismiss alert + */ + closeButtonAriaLabel?: string; + /** + * @control select + * @default derived from color + */ + iconName?: DynamicIconName; + /** @control object */ + startContent?: ReactNode; + /** @control object */ + endContent?: ReactNode; + /** @control text */ + className?: string; +}; diff --git a/src/components/atoms/alert/useAlert.ts b/src/components/atoms/alert/useAlert.ts new file mode 100644 index 00000000..f7d0971e --- /dev/null +++ b/src/components/atoms/alert/useAlert.ts @@ -0,0 +1,307 @@ +import type { ComponentProps, KeyboardEvent } from 'react'; +import { Children, useCallback, useEffect, useId, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import type { DynamicIconName } from '@/types'; +import type { AlertProps } from './types'; +import { alertVariants, defaultAlertIcons } from './types'; + +const EXIT_DURATION_MS = 200; + +const hasContent = (node: AlertProps['children']) => { + return Children.toArray(node).some((child) => { + if (typeof child === 'string') { + return child.trim().length > 0; + } + + return child !== null && child !== undefined; + }); +}; + +const getPrefersReducedMotion = () => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +}; + +const usePrefersReducedMotion = () => { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(getPrefersReducedMotion); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const updatePreference = () => { + setPrefersReducedMotion(mediaQuery.matches); + }; + + updatePreference(); + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', updatePreference); + + return () => { + mediaQuery.removeEventListener('change', updatePreference); + }; + } + + mediaQuery.addListener(updatePreference); + + return () => { + mediaQuery.removeListener(updatePreference); + }; + }, []); + + return prefersReducedMotion; +}; + +type UseAlertReturn = { + shouldRender: boolean; + dismissible: boolean; + closeButtonAriaLabel: string; + rootClassName: string; + innerClassName: string; + contentClassName: string; + startContentClassName: string; + iconClassName: string; + textContentClassName: string; + titleClassName: string; + descriptionClassName: string; + subtitleClassName: string; + bodyClassName: string; + endContentClassName: string; + closeButtonClassName: string; + titleId?: string; + descriptionId?: string; + hasTitle: boolean; + hasSubtitle: boolean; + hasBody: boolean; + hasStartContent: boolean; + hasEndContent: boolean; + showDefaultIcon: boolean; + resolvedIconName: DynamicIconName; + handleDismiss: () => void; + handleCloseKeyDown: (event: KeyboardEvent) => void; + rootProps: ComponentProps<'div'> & { + 'aria-describedby': string | undefined; + 'aria-labelledby': string | undefined; + 'data-state': 'open' | 'closed'; + role: 'alert'; + }; + pieces: { + title: AlertProps['title']; + subtitle: AlertProps['subtitle']; + children: AlertProps['children']; + startContent: AlertProps['startContent']; + endContent: AlertProps['endContent']; + }; +}; + +export const useAlert = (props: AlertProps): UseAlertReturn => { + const { + title, + subtitle, + children, + variant = 'solid', + color = 'primary', + rounded = true, + dismissible = false, + defaultOpen = true, + open: openProp, + onOpenChange, + closeButtonAriaLabel = 'Dismiss alert', + iconName, + startContent, + endContent, + className, + ...rest + } = props; + + const prefersReducedMotion = usePrefersReducedMotion(); + const isControlled = typeof openProp === 'boolean'; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const open = isControlled ? openProp : uncontrolledOpen; + const [isPresent, setIsPresent] = useState(open); + const [isVisible, setIsVisible] = useState(open); + const exitTimeoutRef = useRef(null); + const enterFrameRef = useRef(null); + const idBase = useId().replaceAll(':', ''); + + const clearPendingExit = useCallback(() => { + if (exitTimeoutRef.current) { + clearTimeout(exitTimeoutRef.current); + exitTimeoutRef.current = null; + } + }, []); + + const clearPendingEnterFrame = useCallback(() => { + if (enterFrameRef.current !== null) { + window.cancelAnimationFrame(enterFrameRef.current); + enterFrameRef.current = null; + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + clearPendingExit(); + clearPendingEnterFrame(); + + if (open) { + if (!isPresent) { + setIsPresent(true); + if (prefersReducedMotion) { + setIsVisible(true); + return; + } + + setIsVisible(false); + enterFrameRef.current = window.requestAnimationFrame(() => { + setIsVisible(true); + enterFrameRef.current = null; + }); + return; + } + + setIsVisible(true); + return; + } + + if (!isPresent) { + return; + } + + if (prefersReducedMotion) { + setIsVisible(false); + setIsPresent(false); + return; + } + + setIsVisible(false); + exitTimeoutRef.current = window.setTimeout(() => { + setIsPresent(false); + exitTimeoutRef.current = null; + }, EXIT_DURATION_MS); + + return () => { + clearPendingExit(); + clearPendingEnterFrame(); + }; + }, [clearPendingEnterFrame, clearPendingExit, isPresent, open, prefersReducedMotion]); + + useEffect(() => { + return () => { + clearPendingExit(); + clearPendingEnterFrame(); + }; + }, [clearPendingEnterFrame, clearPendingExit]); + + const requestOpenChange = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange] + ); + + const hasTitle = hasContent(title); + const hasSubtitle = hasContent(subtitle); + const hasBody = hasContent(children); + const hasTextContent = hasTitle || hasSubtitle || hasBody; + const hasStartContent = hasContent(startContent); + const hasEndContent = hasContent(endContent); + const resolvedIconName = iconName ?? defaultAlertIcons[color]; + const showDefaultIcon = !hasStartContent; + const titleId = hasTitle ? `alert-${idBase}-title` : undefined; + const descriptionId = hasSubtitle || hasBody ? `alert-${idBase}-description` : undefined; + + const closeButtonHoverClassName = 'hover:bg-surface-raised-light dark:hover:bg-white-tint-faint'; + const defaultIconClassName = (() => { + switch (color) { + case 'success': + return 'text-success-light dark:text-success'; + case 'warning': + return 'text-warning-light dark:text-warning'; + case 'danger': + return 'text-error-light dark:text-error'; + default: + return 'text-text-secondary-light dark:text-text-secondary-dark'; + } + })(); + + const rootClassName = cn( + alertVariants({ variant, color, rounded }), + isVisible ? '[grid-template-rows:1fr] opacity-100' : 'pointer-events-none [grid-template-rows:0fr] opacity-0', + className + ); + + const handleDismiss = useCallback(() => { + requestOpenChange(false); + }, [requestOpenChange]); + + const handleCloseKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Space' || event.key === 'Spacebar') { + event.preventDefault(); + handleDismiss(); + } + }, + [handleDismiss] + ); + + return { + shouldRender: hasTextContent && isPresent, + dismissible, + closeButtonAriaLabel, + rootClassName, + innerClassName: 'min-h-0 overflow-hidden', + contentClassName: cn('flex items-start gap-3 px-4', hasSubtitle || hasBody ? 'py-4' : 'py-3'), + startContentClassName: 'mt-0.5 flex shrink-0 items-center justify-center text-current', + iconClassName: defaultIconClassName, + textContentClassName: 'min-w-0 flex-1', + titleClassName: 'font-bold text-sm leading-6', + descriptionClassName: cn('min-w-0 text-sm leading-6', hasTitle && 'mt-1', (hasSubtitle || hasBody) && 'space-y-2'), + subtitleClassName: 'opacity-80', + bodyClassName: 'opacity-90', + endContentClassName: 'flex shrink-0 items-start gap-2 self-start', + closeButtonClassName: cn( + '-mt-1.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-transparent text-current/70', + 'transition-[background-color,color,box-shadow] duration-200 ease-out', + 'hover:text-current focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark', + closeButtonHoverClassName + ), + titleId, + descriptionId, + hasTitle, + hasSubtitle, + hasBody, + hasStartContent, + hasEndContent, + showDefaultIcon, + resolvedIconName, + handleDismiss, + handleCloseKeyDown, + rootProps: { + ...rest, + role: 'alert', + 'aria-labelledby': titleId, + 'aria-describedby': descriptionId, + 'data-state': isVisible ? 'open' : 'closed' + }, + pieces: { + title, + subtitle, + children, + startContent, + endContent + } + }; +}; diff --git a/src/index.ts b/src/index.ts index a2427189..7907379b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import './styles/global.css'; // ─── Atoms ─────────────────────────────────────────────────────────────────── +export { Alert } from './components/atoms/alert'; export { Avatar } from './components/atoms/avatar'; export { Badge } from './components/atoms/badge'; export { Button } from './components/atoms/button';