diff --git a/apps/docs/content/docs/components/actions/dialog.mdx b/apps/docs/content/docs/components/actions/dialog.mdx new file mode 100644 index 00000000..e0831cea --- /dev/null +++ b/apps/docs/content/docs/components/actions/dialog.mdx @@ -0,0 +1,92 @@ +--- +title: Dialog +description: A modal dialog for focused interactions that require the user's attention. +--- + +`Dialog` presents content in a focused overlay, blocking interaction with the page behind it. Use it +for confirmations, forms, and other tasks that benefit from a dedicated layer. + +```tsx +import { Dialog, DialogTrigger } from '@luke-ui/react/dialog'; +import { Button } from '@luke-ui/react/button'; + + + + Dialog content +; +``` + +## Anatomy + +A dialog is composed of several subcomponents that build on +[React Aria Components](https://react-spectrum.adobe.com/react-aria-components.html). + + + +## Close behaviour + +By default clicking outside the dialog or pressing Escape dismisses it. Set +`isDismissable={false}` on `Dialog` to require an explicit action. + +The `Dialog` component renders a close button in the top-right corner by default. Set +`showCloseButton={false}` to remove it. + +A text close button can be placed in the footer with `showCloseButton` on `DialogFooter`. + +```tsx + + + + Confirm + + + + + +``` + +## Accessibility + +The dialog manages focus automatically: focus moves inside when opened and returns to the trigger +when closed. The overlay receives `role="dialog"` and is labelled by the `DialogTitle` component. + +## Props + +### Dialog + + + +### DialogTrigger + + + +### DialogTitle + + + +### DialogDescription + + + +### DialogHeader + + + +### DialogFooter + + + +### DialogClose + + + +### DialogOverlay + + diff --git a/apps/docs/content/docs/components/actions/meta.json b/apps/docs/content/docs/components/actions/meta.json index 87422c15..571b2dea 100644 --- a/apps/docs/content/docs/components/actions/meta.json +++ b/apps/docs/content/docs/components/actions/meta.json @@ -1,4 +1,4 @@ { "title": "Actions", - "pages": ["button", "icon-button", "link"] + "pages": ["button", "dialog", "icon-button", "link"] } diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index 1849f159..f45b8c43 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -1,4 +1,4 @@ { "title": "Components", - "pages": ["actions", "feedback", "forms", "layout", "typography", "visuals", "primitives"] + "pages": ["actions", "feedback", "forms", "layout", "primitives", "typography", "visuals"] } diff --git a/apps/docs/src/examples/dialog/default.tsx b/apps/docs/src/examples/dialog/default.tsx new file mode 100644 index 00000000..0fb884a1 --- /dev/null +++ b/apps/docs/src/examples/dialog/default.tsx @@ -0,0 +1,29 @@ +import { Button } from '@luke-ui/react/button'; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@luke-ui/react/dialog'; + +export default function DefaultDialog() { + return ( + + + + + Confirm action + + Are you sure you want to proceed with this action? This cannot be undone. + + + + + + + + + ); +} diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 08e1f636..ea6a0bb4 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -19,6 +19,7 @@ "./button/primitive": "./dist/button/primitive/index.js", "./combobox-field": "./dist/combobox-field/index.js", "./combobox-field/primitive": "./dist/combobox-field/primitive/index.js", + "./dialog": "./dist/dialog/index.js", "./emoji": "./dist/emoji/index.js", "./field/primitive": "./dist/field/primitive/index.js", "./heading": "./dist/heading/index.js", diff --git a/packages/@luke-ui/react/src/dialog/dialog.docs.md b/packages/@luke-ui/react/src/dialog/dialog.docs.md new file mode 100644 index 00000000..7429c253 --- /dev/null +++ b/packages/@luke-ui/react/src/dialog/dialog.docs.md @@ -0,0 +1,16 @@ +`Dialog` from `@luke-ui/react/dialog`. + +```tsx +Label +``` + +## Best Practices + +| Guidance | Practices | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| _Do/Don't_ | _Add a row per practice worth calling out. Rows don't need to pair up. Delete this section if there's no useful guidance to give._ | + +## Accessibility + +_Describe accessibility considerations (e.g. required aria-label, keyboard behavior, screen reader +announcements). Delete this section if there's nothing beyond default semantics._ diff --git a/packages/@luke-ui/react/src/dialog/dialog.stories.tsx b/packages/@luke-ui/react/src/dialog/dialog.stories.tsx new file mode 100644 index 00000000..eaa9d9b5 --- /dev/null +++ b/packages/@luke-ui/react/src/dialog/dialog.stories.tsx @@ -0,0 +1,141 @@ +import { Button } from '@luke-ui/react/button'; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@luke-ui/react/dialog'; +import { expect, userEvent, waitFor } from 'storybook/test'; +import preview from '../../.storybook/preview.js'; + +const meta = preview.meta({ + component: Dialog, + tags: ['actions'], + title: 'Actions/Dialog', +}); + +/** + * A basic dialog with header, description, and action buttons. + */ +export const Default = meta.story({ + render: () => ( + + + + + Confirm action + + Are you sure you want to proceed with this action? This cannot be undone. + + + + + + + + + ), + play: async ({ canvas, step }) => { + const trigger = canvas.getByRole('button', { name: 'Open Dialog' }); + + await step('opens the dialog on trigger press', async () => { + await userEvent.click(trigger); + const dialog = await waitFor(() => canvas.getByRole('dialog')); + await expect(dialog).toBeVisible(); + await expect(canvas.getByText('Confirm action')).toBeVisible(); + }); + + await step('closes the dialog via the X button', async () => { + const closeButton = canvas.getByRole('button', { name: 'Close' }); + await userEvent.click(closeButton); + await waitFor(() => expect(canvas.queryByRole('dialog')).toBeNull()); + }); + }, +}); + +/** + * A dialog without the close button, useful for mandatory choices. + */ +export const WithoutCloseButton = meta.story({ + render: () => ( + + + + Choose an option + + You must select one of the available options to continue. + + + + + + + + ), + play: async ({ canvas, step }) => { + const trigger = canvas.getByRole('button', { name: 'Open Dialog' }); + + await step('opens the dialog without a close button', async () => { + await userEvent.click(trigger); + const dialog = await waitFor(() => canvas.getByRole('dialog')); + await expect(dialog).toBeVisible(); + await expect(canvas.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + }, +}); + +/** + * A dialog with a close button in the footer. + */ +export const WithFooterClose = meta.story({ + render: () => ( + + + + + Terms and conditions + + Please review and accept the terms before continuing. + + + + + + + + ), + play: async ({ canvas, step }) => { + const trigger = canvas.getByRole('button', { name: 'Open Dialog' }); + + await step('opens and closes via footer button', async () => { + await userEvent.click(trigger); + await waitFor(() => expect(canvas.getByRole('dialog')).toBeVisible()); + + const footerClose = canvas.getByRole('button', { name: 'Close' }); + await userEvent.click(footerClose); + await waitFor(() => expect(canvas.queryByRole('dialog')).toBeNull()); + }); + }, +}); + +/** + * A non-dismissable dialog that requires explicit action. + */ +export const NonDismissable = meta.story({ + render: () => ( + + + + Required action + + This dialog cannot be closed by clicking outside or pressing Escape. + + + + + + + ), +}); diff --git a/packages/@luke-ui/react/src/dialog/index.tsx b/packages/@luke-ui/react/src/dialog/index.tsx new file mode 100644 index 00000000..b61f10b1 --- /dev/null +++ b/packages/@luke-ui/react/src/dialog/index.tsx @@ -0,0 +1,241 @@ +'use client'; + +import type { ComponentProps, JSX, ReactNode } from 'react'; +import { Button as RacButton } from 'react-aria-components/Button'; +import { + Dialog as RacDialog, + DialogTrigger as RacDialogTrigger, + Heading as RacHeading, +} from 'react-aria-components/Dialog'; +import { Modal as RacModal, ModalOverlay as RacModalOverlay } from 'react-aria-components/Modal'; +import type { + ButtonProps as RacButtonProps, + DialogProps as RacDialogProps, + DialogTriggerProps as RacDialogTriggerProps, + HeadingProps as RacHeadingProps, + ModalOverlayProps as RacModalOverlayProps, +} from 'react-aria-components/Modal'; +import * as styles from '../recipes/dialog.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import type { Prettify } from '../types/prettify.js'; +import { cx } from '../utils/index.js'; + +export type { RacDialogProps as DialogPrimitiveProps }; +export type { RacDialogTriggerProps as DialogTriggerPrimitiveProps }; + +interface _DialogTriggerProps extends RacDialogTriggerProps {} + +/** + * Props for `DialogTrigger`. + * + * @tier composed + */ +export type DialogTriggerProps = Prettify<_DialogTriggerProps>; + +/** Wraps content that opens the dialog on interaction. */ +export function DialogTrigger(props: DialogTriggerProps): JSX.Element { + return ; +} + +type _DialogCloseOmit = DistributiveOmit; + +interface _DialogCloseProps extends _DialogCloseOmit { + className?: string; +} + +/** + * Props for `DialogClose`. + * + * @tier composed + */ +export type DialogCloseProps = Prettify<_DialogCloseProps>; + +/** Renders a close button inside a dialog when placed with `slot="close"`. */ +export function DialogClose({ className, ...props }: DialogCloseProps): JSX.Element { + return ( + + ); +} + +type _DialogOverlayOmit = DistributiveOmit; + +interface _DialogOverlayProps extends _DialogOverlayOmit { + className?: string; + children: ReactNode; +} + +/** + * Props for `DialogOverlay`. + * + * @tier composed + */ +export type DialogOverlayProps = Prettify<_DialogOverlayProps>; + +/** Backdrop overlay for the dialog. */ +export function DialogOverlay({ className, children, ...props }: DialogOverlayProps): JSX.Element { + return ( + + {children} + + ); +} + +interface _DialogProps { + className?: string; + children: ReactNode; + /** + * Whether to show the close button in the top-right corner. + * @default true + */ + showCloseButton?: boolean; + /** + * Whether clicking outside or pressing Escape dismisses the dialog. + * @default true + */ + isDismissable?: RacModalOverlayProps['isDismissable']; + /** Whether the dialog is open (controlled). */ + isOpen?: RacModalOverlayProps['isOpen']; + /** Whether the dialog is open by default (uncontrolled). */ + defaultOpen?: RacModalOverlayProps['defaultOpen']; + /** Handler called when the dialog's open state changes. */ + onOpenChange?: RacModalOverlayProps['onOpenChange']; +} + +/** + * Props for `Dialog`. + * + * @tier composed + */ +export type DialogProps = Prettify<_DialogProps>; + +/** Modal dialog with overlay, content panel, and optional close button. */ +export function Dialog({ + className, + children, + isDismissable = true, + showCloseButton = true, + ...props +}: DialogProps): JSX.Element { + return ( + + + + {children} + {showCloseButton && } + + + + ); +} + +function InternalCloseButton(): JSX.Element { + return ( + + + + + + + ); +} + +/** + * Props for `DialogHeader`. + * + * @tier composed + */ +export type DialogHeaderProps = ComponentProps<'div'>; + +/** Header section of the dialog, typically containing `DialogTitle` and `DialogDescription`. */ +export function DialogHeader({ className, ...props }: DialogHeaderProps): JSX.Element { + return ( +
+ ); +} + +/** + * Props for `DialogFooter`. + * + * @tier composed + */ +export type DialogFooterProps = ComponentProps<'div'> & { + /** + * Whether to show a text close button at the end of the footer. + * @default false + */ + showCloseButton?: boolean; +}; + +/** Footer section of the dialog, typically containing action buttons. */ +export function DialogFooter({ + className, + children, + showCloseButton = false, + ...props +}: DialogFooterProps): JSX.Element { + return ( +
+ {children} + {showCloseButton && Close} +
+ ); +} + +type _DialogTitleOmit = DistributiveOmit; + +interface _DialogTitleProps extends _DialogTitleOmit { + className?: string; +} + +/** + * Props for `DialogTitle`. + * + * @tier composed + */ +export type DialogTitleProps = Prettify<_DialogTitleProps>; + +/** Dialog title rendered as a heading. */ +export function DialogTitle({ className, ...props }: _DialogTitleProps): JSX.Element { + return ( + + ); +} + +/** + * Props for `DialogDescription`. + * + * @tier composed + */ +export type DialogDescriptionProps = ComponentProps<'div'>; + +/** Descriptive text or content within the dialog body. */ +export function DialogDescription({ className, ...props }: DialogDescriptionProps): JSX.Element { + return ( +
+ ); +} diff --git a/packages/@luke-ui/react/src/recipes/dialog.css.ts b/packages/@luke-ui/react/src/recipes/dialog.css.ts new file mode 100644 index 00000000..04a42ccd --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/dialog.css.ts @@ -0,0 +1,178 @@ +import { keyframes } from '@vanilla-extract/css'; +import type { RecipeVariants } from '@vanilla-extract/recipes'; +import { recipeInLayer } from '../styles/layered-style.css.js'; +import { vars } from '../theme/contract.css.js'; + +const overlayIn = keyframes({ + from: { opacity: 0 }, + to: { opacity: 1 }, +}); + +const overlayOut = keyframes({ + from: { opacity: 1 }, + to: { opacity: 0 }, +}); + +const contentIn = keyframes({ + '0%': { opacity: 0, transform: 'translate(-50%, -50%) scale(0.95)' }, + '100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, +}); + +const contentOut = keyframes({ + '0%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, + '100%': { opacity: 0, transform: 'translate(-50%, -50%) scale(0.95)' }, +}); + +export const dialogOverlay = recipeInLayer('recipes', { + base: { + '@media': { + '(prefers-reduced-motion: reduce)': { + selectors: { + '&[data-entering]': { animation: 'none' }, + '&[data-exiting]': { animation: 'none' }, + }, + }, + }, + backgroundColor: 'rgb(0 0 0 / 0.8)', + inset: 0, + isolation: 'isolate', + position: 'fixed', + selectors: { + '&[data-entering]': { animation: `${overlayIn} 100ms ease-out` }, + '&[data-exiting]': { animation: `${overlayOut} 100ms ease-in` }, + }, + zIndex: 50, + }, +}); + +export const dialogContent = recipeInLayer('recipes', { + base: { + '@media': { + '(prefers-reduced-motion: reduce)': { + selectors: { + '&[data-entering]': { animation: 'none' }, + '&[data-exiting]': { animation: 'none' }, + }, + }, + '(min-width: 640px)': { + maxWidth: '28rem', + }, + }, + '@supports': { + '(backdrop-filter: blur(4px))': { + backdropFilter: 'blur(4px)', + }, + }, + backgroundColor: vars.color.surface.floating, + borderRadius: vars.radius.overlay, + border: `1px solid ${vars.color.border.decorative}`, + boxShadow: vars.depth.overlay, + color: vars.color.text.primary, + display: 'grid', + gap: vars.space[600], + left: '50%', + maxWidth: 'calc(100% - 2rem)', + outline: 'none', + padding: vars.space[600], + position: 'fixed', + selectors: { + '&[data-entering]': { animation: `${contentIn} 100ms ease-out` }, + '&[data-exiting]': { animation: `${contentOut} 100ms ease-in` }, + }, + top: '50%', + transform: 'translate(-50%, -50%)', + width: '100%', + zIndex: 50, + }, +}); + +export const dialog = recipeInLayer('recipes', { + base: { + display: 'inherit', + gap: 'inherit', + outline: 'none', + }, +}); + +export const dialogHeader = recipeInLayer('recipes', { + base: { + display: 'flex', + flexDirection: 'column', + gap: vars.space[200], + }, +}); + +export const dialogFooter = recipeInLayer('recipes', { + base: { + '@media': { + '(min-width: 640px)': { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + }, + display: 'flex', + flexDirection: 'column-reverse', + gap: vars.space[200], + }, +}); + +export const dialogTitle = recipeInLayer('recipes', { + base: { + fontFamily: vars.font.family, + fontSize: vars.font[400].fontSize, + fontWeight: vars.font.weight.heading, + lineHeight: 1, + margin: 0, + }, +}); + +export const dialogDescription = recipeInLayer('recipes', { + base: { + color: vars.color.text.secondary, + fontSize: vars.font[200].fontSize, + }, +}); + +const closeButtonReset = { + alignItems: 'center', + appearance: 'none', + background: 'none', + border: 'none', + color: 'inherit', + cursor: 'pointer', + display: 'inline-flex', + font: 'inherit', + justifyContent: 'center', + outline: 'none', + padding: 0, +} as const; + +export const dialogCloseButton = recipeInLayer('recipes', { + base: { + ...closeButtonReset, + blockSize: vars.controlSize.small, + inlineSize: vars.controlSize.small, + position: 'absolute', + insetBlockStart: vars.space[400], + insetInlineEnd: vars.space[400], + borderRadius: vars.radius.control, + selectors: { + '&[data-hovered="true"]': { + backgroundColor: vars.color.intent.neutral.surface.subtleHover, + }, + '&[data-focus-visible="true"]': { + outline: `2px solid ${vars.color.border.focus}`, + outlineOffset: '2px', + }, + }, + }, +}); + +export type DialogOverlayVariants = RecipeVariants; +export type DialogContentVariants = RecipeVariants; +export type DialogVariants = RecipeVariants; +export type DialogCloseButtonVariants = RecipeVariants; +export type DialogHeaderVariants = RecipeVariants; +export type DialogFooterVariants = RecipeVariants; +export type DialogTitleVariants = RecipeVariants; +export type DialogDescriptionVariants = RecipeVariants; diff --git a/packages/@luke-ui/react/src/recipes/index.ts b/packages/@luke-ui/react/src/recipes/index.ts index f9eba261..f9e7d7b1 100644 --- a/packages/@luke-ui/react/src/recipes/index.ts +++ b/packages/@luke-ui/react/src/recipes/index.ts @@ -1,5 +1,25 @@ export type { ButtonVariants } from '../recipes/button.css.js'; export { button } from '../recipes/button.css.js'; +export type { + DialogCloseButtonVariants, + DialogContentVariants, + DialogDescriptionVariants, + DialogFooterVariants, + DialogHeaderVariants, + DialogOverlayVariants, + DialogTitleVariants, + DialogVariants, +} from '../recipes/dialog.css.js'; +export { + dialog, + dialogCloseButton, + dialogContent, + dialogDescription, + dialogFooter, + dialogHeader, + dialogOverlay, + dialogTitle, +} from '../recipes/dialog.css.js'; export { field, fieldLabel, fieldMessage } from '../recipes/field.css.js'; export type { IconVariants } from '../recipes/icon.css.js'; export { icon } from '../recipes/icon.css.js';