diff --git a/docs/app/data/categories.json b/docs/app/data/categories.json
index 6e8a8644..241185bf 100644
--- a/docs/app/data/categories.json
+++ b/docs/app/data/categories.json
@@ -12,7 +12,7 @@
"containment": {
"name": "Containment",
"description": "Components that contain other components.",
- "items": ["Confirm Modal", "Prompt Modal"]
+ "items": ["Confirm Modal", "Prompt Modal", "Modal"]
},
"navigation": {
"name": "Navigation",
diff --git a/docs/app/react/modal/a11y.mdx b/docs/app/react/modal/a11y.mdx
new file mode 100644
index 00000000..1be10b2b
--- /dev/null
+++ b/docs/app/react/modal/a11y.mdx
@@ -0,0 +1,30 @@
+---
+---
+
+import {
+ WhenToUseAdmonition
+} from '@/app/components/Admonition'
+import OverviewList from '@/app/components/OverviewList'
+
+## Use Cases
+
+
+
+### Interaction & Style
+
+Modals are purposefully interruptive. This means they appear in front of app content and disrupt the flow of content for users who may, for example, be using a screen reader to navigate the page.
+
+As such, modals should be used sparingly and only to provide critical information. Less critical information should be presented in a non-blocking way within the flow of app content.
+
+## Keyboard Navigation
+
+| Keys | Actions |
+| -------- | --------------------------------------------------------------- |
+| Tab | Focus lands on the next interactive element contained in the modal, or the first element if focus is currently on the last element |
+| Shift + Tab | Focus lands on the previous interactive element contained in the modal, or the last element if focus is currently on the first element |
+| Space / Enter | Triggers or commits the action of the focused element |
+| Esc | Closes the modal and returns focus to the element that triggered the modal |
\ No newline at end of file
diff --git a/docs/app/react/modal/components/modal-preview.tsx b/docs/app/react/modal/components/modal-preview.tsx
new file mode 100644
index 00000000..aae63d97
--- /dev/null
+++ b/docs/app/react/modal/components/modal-preview.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { Model } from '@cerberus-design/icons'
+import {
+ Modal,
+ ModalHeader,
+ ModalHeading,
+ ModalDescription,
+ useModal,
+ trapFocus,
+ Button,
+ Portal,
+} from '@cerberus-design/react'
+import { css } from '@cerberus/styled-system/css'
+import { hstack } from '@cerberus/styled-system/patterns'
+
+export function OverviewPreview() {
+ const { modalRef, show, close } = useModal()
+ const handleKeyDown = trapFocus(modalRef)
+
+ return (
+
+
Open Modal
+
+
+
+
+ This is a custom modal
+
+
+ This is a custom modal that is can be whatever you need.
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ )
+}
+
+export function CustomPreview() {
+ const { modalRef, show, close } = useModal()
+ const handleKeyDown = trapFocus(modalRef)
+
+ return (
+
+
+ Enter the Wu
+
+
+
+
+
+
+ Inspectah Deck
+
+
+ Swingin' through your town like your neighborhood Spider-man!
+
+
+
+
+ Close
+
+
+
+
+ )
+}
diff --git a/docs/app/react/modal/dev.mdx b/docs/app/react/modal/dev.mdx
new file mode 100644
index 00000000..dd33e88c
--- /dev/null
+++ b/docs/app/react/modal/dev.mdx
@@ -0,0 +1,177 @@
+---
+npm: '@cerberus-design/react'
+source: 'context/confirm-modal.tsx'
+recipe: ''
+---
+
+import CodePreview from '@/app/components/CodePreview'
+import {
+ NoteAdmonition,
+} from '@/app/components/Admonition'
+import {
+ OverviewPreview,
+ CustomPreview
+} from '@/app/react/modal/components/modal-preview'
+
+```ts
+import {
+ Modal,
+ ModalHeader,
+ ModalHeading,
+ ModalDescription,
+ trapFocus,
+ useModal
+} from '@cerberus-design/react'
+```
+
+## Usage
+
+### Basic Usage
+
+ }>
+```tsx title="some-page.tsx"
+import { Model } from '@cerberus-design/icons'
+import {
+ Modal,
+ ModalHeader,
+ ModalHeading,
+ ModalDescription,
+ Portal,
+ useModal,
+ trapFocus,
+ Button,
+} from '@cerberus-design/react'
+import { hstack } from '@cerberus/styled-system/patterns'
+
+function OverviewPreview() {
+ const { modalRef, show, close } = useModal()
+ const handleKeyDown = trapFocus(modalRef)
+
+ return (
+
+
Open Modal
+
+
+
+
+ This is a custom modal
+
+
+ This is a custom modal that is can be whatever you need.
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ )
+}
+```
+
+
+## Customization
+
+You can customize each Modal component like you would any other component.
+
+ }>
+```tsx title="some-page.tsx"
+import { Model } from '@cerberus-design/icons'
+import {
+ Modal,
+ ModalHeader,
+ ModalHeading,
+ ModalDescription,
+ Portal,
+ useModal,
+ trapFocus,
+ Button,
+} from '@cerberus-design/react'
+import { css } from '@cerberus/styled-system/css'
+
+function CustomPreview() {
+ const { modalRef, show, close } = useModal()
+ const handleKeyDown = trapFocus(modalRef)
+
+ return (
+
+
+ Enter the Wu
+
+
+
+
+
+
+ Inspectah Deck
+
+
+ Swingin' through your town like your neighborhood Spider-man!
+
+
+
+
+ Close
+
+
+
+
+ )
+}
+```
+
+
+## API
+
+```ts showLineNumbers=false
+define function Modal(props: HTMLAttributes): ReactNode
+
+define function ModalHeader(props: HTMLAttributes): ReactNode
+
+define function ModalHeading(props: HTMLAttributes): ReactNode
+
+define function ModalDescription(props: HTMLAttributes): ReactNode
+````
diff --git a/docs/app/react/modal/guidelines.mdx b/docs/app/react/modal/guidelines.mdx
new file mode 100644
index 00000000..0a938890
--- /dev/null
+++ b/docs/app/react/modal/guidelines.mdx
@@ -0,0 +1,23 @@
+---
+---
+
+import CodePreview from '@/app/components/CodePreview'
+import {
+ WhenToUseAdmonition,
+ WhenNotToUseAdmonition,
+} from '@/app/components/Admonition'
+import {
+ OverviewPreview
+} from '@/app/react/modal/components/modal-preview'
+
+## Custom Modal Usage
+
+
+
+ } />
+
+### Use sparingly
+
+Modals should be used sparingly, and only for important decisions or actions that cannot be undone. They should not be used for trivial actions or actions that can be easily undone.
+
+For most actions, a simple **Notification** message is sufficient to inform the user of the result of their action (or allow them to "undo" it).
\ No newline at end of file
diff --git a/docs/app/react/modal/overview.mdx b/docs/app/react/modal/overview.mdx
new file mode 100644
index 00000000..a686b1e4
--- /dev/null
+++ b/docs/app/react/modal/overview.mdx
@@ -0,0 +1,28 @@
+---
+heading: 'Modal'
+description: 'Modals provide important prompts in a user flow.'
+a11y: 'touch-target'
+---
+
+import CodePreview from '@/app/components/CodePreview'
+import OverviewList from '@/app/components/OverviewList'
+import {
+ OverviewPreview
+} from '@/app/react/modal/components/modal-preview'
+
+
+
+## Example
+
+ } />
+
+## Resources
+
+| Name | Resource | Status |
+| -------- | -------- | ---------------------------------------------------- |
+| Figma | [Design Kit (Figma)](https://www.figma.com/design/ducwqOCxoxcWc3ReV3FYd8/Digital-University-Component-Library?m=auto&node-id=0-1) | Private |
\ No newline at end of file
diff --git a/docs/app/react/modal/page.tsx b/docs/app/react/modal/page.tsx
new file mode 100644
index 00000000..5da364ef
--- /dev/null
+++ b/docs/app/react/modal/page.tsx
@@ -0,0 +1,54 @@
+import ApiLinks from '@/app/components/ApiLinks'
+import {
+ TabPageContent,
+ TabPageContentLayout,
+} from '../../components/PageLayout'
+import FeatureHeader from '@/app/components/FeatureHeader'
+import type { MatchFeatureKind } from '@/app/components/MatchFeatureImg'
+import PageTabs from '@/app/components/PageTabs'
+
+import Overview, { frontmatter } from './overview.mdx'
+import Guidelines from './guidelines.mdx'
+import Dev, { frontmatter as devFrontmatter } from './dev.mdx'
+import A11y from './a11y.mdx'
+
+export default function ModalPage() {
+ return (
+ <>
+
+
+
+
+
+
+ }
+ guidelines={
+
+
+
+ }
+ dev={
+
+
+
+
+
+
+ }
+ a11y={
+
+
+
+ }
+ />
+
+ >
+ )
+}
diff --git a/docs/app/react/prompt-modal/components/prompt-modal-features.tsx b/docs/app/react/prompt-modal/components/prompt-modal-features.tsx
index 4e3665d0..3aa188f3 100644
--- a/docs/app/react/prompt-modal/components/prompt-modal-features.tsx
+++ b/docs/app/react/prompt-modal/components/prompt-modal-features.tsx
@@ -21,7 +21,7 @@ export function NonDestructiveFeature() {
cancelText: NOPE,
})
if (userPrompt === key) setUserValue('Super secret stuff')
- }, [confirm])
+ }, [prompt])
return (
<>
@@ -91,7 +91,7 @@ export function PromptOverviewFeature() {
cancelText: NOPE,
})
if (userPrompt === key) setUserValue('Super secret stuff')
- }, [confirm])
+ }, [prompt])
const handleDestructiveClick = useCallback(async () => {
const key = 'DELETE'
diff --git a/docs/app/react/side-nav.json b/docs/app/react/side-nav.json
index 4bc501fc..145f19b1 100644
--- a/docs/app/react/side-nav.json
+++ b/docs/app/react/side-nav.json
@@ -116,20 +116,27 @@
},
{
"id": "2:o",
+ "label": "Modal",
+ "route": "/react/modal",
+ "tag": "next",
+ "type": "route"
+ },
+ {
+ "id": "2:p",
"label": "Show",
"route": "/react/show",
"tag": "",
"type": "route"
},
{
- "id": "2:p",
+ "id": "2:q",
"label": "Portal",
"route": "/react/portal",
"tag": "next",
"type": "route"
},
{
- "id": "2:q",
+ "id": "2:r",
"label": "Feature Flags",
"route": "/react/feature-flags",
"tag": "next",
@@ -142,23 +149,42 @@
},
{
"id": "3:a",
+ "label": "useModal",
+ "route": "/react/use-modal",
+ "tag": "next",
+ "type": "route"
+ },
+ {
+ "id": "3:b",
"label": "useTheme",
"route": "/react/use-theme",
"tag": "",
"type": "route"
},
{
- "id": "3:b",
+ "id": "3:c",
"label": "useThemeContext",
"route": "/react/use-theme-context",
"tag": "",
"type": "route"
},
{
- "id": "3:c",
+ "id": "3:d",
"label": "useToggle",
"route": "/react/use-toggle",
"tag": "",
"type": "route"
+ },
+ {
+ "id": "4",
+ "label": "Helpers",
+ "type": "heading"
+ },
+ {
+ "id": "4:a",
+ "label": "trapFocus",
+ "route": "/react/trap-focus",
+ "tag": "next",
+ "type": "route"
}
]
diff --git a/docs/app/react/trap-focus/doc.mdx b/docs/app/react/trap-focus/doc.mdx
new file mode 100644
index 00000000..ed07e346
--- /dev/null
+++ b/docs/app/react/trap-focus/doc.mdx
@@ -0,0 +1,58 @@
+---
+npm: '@cerberus-design/react'
+source: 'aria-helpers/trap-focus.aria.ts'
+recipe: ''
+---
+
+import {
+ WhenToUseAdmonition
+} from '@/app/components/Admonition'
+
+# Trap Focus
+
+The `trapFocus` helper allows you to trap the focus within a Modal.
+
+
+
+## Usage
+
+```tsx title="custom-modal.tsx" {10,16}
+import {
+ Modal,
+ Button,
+ trapFocus,
+ useModal
+} from '@cerberus-design/react'
+
+function SomePage() {
+ const { modalRef, show, close } = useModal()
+ const handleKeyDown = trapFocus(modalRef)
+
+ return (
+
+ Show Modal
+
+
+ This is a custom modal!
+
+ Close
+
+
+
+ )
+}
+```
+
+## API
+
+```ts showLineNumbers=false
+define function useModal(modalRef: RefObject): KeyboardEventHandler
+```
+
+### Arguments
+
+The `trapFocus` helper accepts a single argument:
+
+| Name | Default | Description |
+| ------------ | ---------- | ------------------------------------------------ |
+| ref | null | The `ref` of the dialog element to trap focus within. |
diff --git a/docs/app/react/trap-focus/page.tsx b/docs/app/react/trap-focus/page.tsx
new file mode 100644
index 00000000..e1df0bc9
--- /dev/null
+++ b/docs/app/react/trap-focus/page.tsx
@@ -0,0 +1,21 @@
+import ApiLinks from '@/app/components/ApiLinks'
+import OnThisPage from '../../components/OnThisPage'
+import { PageMainContent, PageSections } from '../../components/PageLayout'
+import UseThemeDoc, { frontmatter } from './doc.mdx'
+
+export default function TrapFocusPage() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/docs/app/react/use-modal/doc.mdx b/docs/app/react/use-modal/doc.mdx
new file mode 100644
index 00000000..57cc537a
--- /dev/null
+++ b/docs/app/react/use-modal/doc.mdx
@@ -0,0 +1,60 @@
+---
+npm: '@cerberus-design/react'
+source: 'hooks/useModal.ts'
+recipe: ''
+---
+
+import {
+ WhenToUseAdmonition
+} from '@/app/components/Admonition'
+
+# useModal
+
+The `useModal` hook allows you to manage displaying a Modal.
+
+
+
+## Usage
+
+```tsx title="custom-modal.tsx" {9}
+import {
+ Modal,
+ Button,
+ trapFocus,
+ useModal
+} from '@cerberus-design/react'
+
+function SomePage() {
+ const { modalRef, show, close } = useModal()
+ const handleKeyDown = trapFocus(modalRef)
+
+ return (
+
+ Show Modal
+
+
+ This is a custom modal!
+
+ Close
+
+
+
+ )
+}
+```
+
+## API
+
+```ts showLineNumbers=false
+interface UseModalReturnValue {
+ modalRef: RefObject
+ show: () => void
+ close: () => void
+}
+
+define function useModal(): UseModalReturnValue
+```
+
+### Arguments
+
+The `useModal` hook does not take any arguments.
diff --git a/docs/app/react/use-modal/page.tsx b/docs/app/react/use-modal/page.tsx
new file mode 100644
index 00000000..b53714b7
--- /dev/null
+++ b/docs/app/react/use-modal/page.tsx
@@ -0,0 +1,21 @@
+import ApiLinks from '@/app/components/ApiLinks'
+import OnThisPage from '../../components/OnThisPage'
+import { PageMainContent, PageSections } from '../../components/PageLayout'
+import UseThemeDoc, { frontmatter } from './doc.mdx'
+
+export default function UseModalPage() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/react/src/components/Modal.tsx b/packages/react/src/components/Modal.tsx
new file mode 100644
index 00000000..7a29e107
--- /dev/null
+++ b/packages/react/src/components/Modal.tsx
@@ -0,0 +1,37 @@
+import { cx } from '@cerberus-design/styled-system/css'
+import { modal } from '@cerberus-design/styled-system/recipes'
+import { forwardRef, type ForwardedRef, type HTMLAttributes } from 'react'
+
+/**
+ * This module contains the Modal root component for a customizable modal.
+ * @module
+ */
+
+// Modal
+
+export type ModalProps = HTMLAttributes
+
+function ModalEl(props: ModalProps, ref: ForwardedRef) {
+ return (
+
+ )
+}
+
+/**
+ * The Modal component is the root element for a customizable modal.
+ * @example
+ * ```tsx
+ * const { modalRef } = useModal()
+ *
+ *
+ *
+ * Modal Heading
+ * Modal description
+ *
+ * ```
+ */
+export const Modal = forwardRef(ModalEl)
diff --git a/packages/react/src/components/ModalDescription.tsx b/packages/react/src/components/ModalDescription.tsx
new file mode 100644
index 00000000..e7489c61
--- /dev/null
+++ b/packages/react/src/components/ModalDescription.tsx
@@ -0,0 +1,23 @@
+import { cx } from '@cerberus-design/styled-system/css'
+import { modal } from '@cerberus-design/styled-system/recipes'
+import type { HTMLAttributes } from 'react'
+
+/**
+ * This module contains the ModalDescription component for a customizable modal.
+ * @module
+ */
+
+export type ModalDescriptionProps = HTMLAttributes
+
+/**
+ * The ModalDescription component is a heading element for a customizable modal.
+ * @example
+ * ```tsx
+ *
+ * Modal Heading
+ *
+ * ```
+ */
+export function ModalDescription(props: ModalDescriptionProps) {
+ return
+}
diff --git a/packages/react/src/components/ModalHeader.tsx b/packages/react/src/components/ModalHeader.tsx
new file mode 100644
index 00000000..db1c0937
--- /dev/null
+++ b/packages/react/src/components/ModalHeader.tsx
@@ -0,0 +1,37 @@
+import { cx } from '@cerberus-design/styled-system/css'
+import { vstack } from '@cerberus-design/styled-system/patterns'
+import type { HTMLAttributes } from 'react'
+
+/**
+ * This module contains the ModalHeader component for a customizable modal.
+ * @module
+ */
+
+export type ModalHeaderProps = HTMLAttributes
+
+/**
+ * The ModalHeader component is a header element for a customizable modal.
+ * @example
+ * ```tsx
+ *
+ *
+ * Modal Heading
+ *
+ *
+ * ```
+ */
+export function ModalHeader(props: ModalHeaderProps) {
+ return (
+
+ )
+}
diff --git a/packages/react/src/components/ModalHeading.tsx b/packages/react/src/components/ModalHeading.tsx
new file mode 100644
index 00000000..8266d20d
--- /dev/null
+++ b/packages/react/src/components/ModalHeading.tsx
@@ -0,0 +1,23 @@
+import { cx } from '@cerberus-design/styled-system/css'
+import { modal } from '@cerberus-design/styled-system/recipes'
+import type { HTMLAttributes } from 'react'
+
+/**
+ * This module contains the ModalHeading component for a customizable modal.
+ * @module
+ */
+
+export type ModalHeadingProps = HTMLAttributes
+
+/**
+ * The ModalHeading component is a heading element for a customizable modal.
+ * @example
+ * ```tsx
+ *
+ * Modal Heading
+ *
+ * ```
+ */
+export function ModalHeading(props: ModalHeadingProps) {
+ return
+}
diff --git a/packages/react/src/context/confirm-modal.tsx b/packages/react/src/context/confirm-modal.tsx
index 1cba634a..95e99009 100644
--- a/packages/react/src/context/confirm-modal.tsx
+++ b/packages/react/src/context/confirm-modal.tsx
@@ -13,12 +13,16 @@ import {
import { Portal } from '../components/Portal'
import { Button } from '../components/Button'
import { css } from '@cerberus-design/styled-system/css'
-import { hstack, vstack } from '@cerberus-design/styled-system/patterns'
+import { hstack } from '@cerberus-design/styled-system/patterns'
import { $cerberusIcons } from '../config/defineIcons'
-import { modal } from '@cerberus-design/styled-system/recipes'
import { trapFocus } from '../aria-helpers/trap-focus.aria'
import { ModalIcon } from '../components/ModalIcon'
import { Show } from '../components/Show'
+import { Modal } from '../components/Modal'
+import { useModal } from '../hooks/useModal'
+import { ModalHeader } from '../components/ModalHeader'
+import { ModalHeading } from '../components/ModalHeading'
+import { ModalDescription } from '../components/ModalDescription'
/**
* This module provides a context and hook for the confirm modal.
@@ -71,34 +75,39 @@ export interface ConfirmModalProviderProps {}
export function ConfirmModal(
props: PropsWithChildren,
) {
- const dialogRef = useRef(null)
+ const { modalRef, show, close } = useModal()
const resolveRef = useRef(null)
const [content, setContent] = useState(null)
- const focusTrap = trapFocus(dialogRef)
+ const focusTrap = trapFocus(modalRef)
const ConfirmIcon = $cerberusIcons.confirmModal
const palette = useMemo(
() => (content?.kind === 'destructive' ? 'danger' : 'action'),
[content],
)
- const styles = modal()
- const handleChoice = useCallback((e: MouseEvent) => {
- const target = e.currentTarget as HTMLButtonElement
- if (target.value === 'true') {
- resolveRef.current?.(true)
- }
- resolveRef.current?.(false)
- dialogRef?.current?.close()
- }, [])
+ const handleChoice = useCallback(
+ (e: MouseEvent) => {
+ const target = e.currentTarget as HTMLButtonElement
+ if (target.value === 'true') {
+ resolveRef.current?.(true)
+ }
+ resolveRef.current?.(false)
+ close()
+ },
+ [close],
+ )
- const handleShow = useCallback((options: ShowConfirmModalOptions) => {
- return new Promise((resolve) => {
- setContent({ ...options, kind: options.kind || 'non-destructive' })
- dialogRef?.current?.showModal()
- resolveRef.current = resolve
- })
- }, [])
+ const handleShow = useCallback(
+ (options: ShowConfirmModalOptions) => {
+ return new Promise((resolve) => {
+ setContent({ ...options, kind: options.kind || 'non-destructive' })
+ show()
+ resolveRef.current = resolve
+ })
+ },
+ [show],
+ )
const value = useMemo(
() => ({
@@ -112,14 +121,8 @@ export function ConfirmModal(
{props.children}
-
-
+
+
- {content?.heading}
- {content?.description}
-
+ {content?.heading}
+ {content?.description}
+
-
+
)
diff --git a/packages/react/src/context/prompt-modal.tsx b/packages/react/src/context/prompt-modal.tsx
index cf7469c7..ada9cd3f 100644
--- a/packages/react/src/context/prompt-modal.tsx
+++ b/packages/react/src/context/prompt-modal.tsx
@@ -15,7 +15,6 @@ import { Portal } from '../components/Portal'
import { Button } from '../components/Button'
import { css } from '@cerberus-design/styled-system/css'
import { hstack, vstack } from '@cerberus-design/styled-system/patterns'
-import { modal } from '@cerberus-design/styled-system/recipes'
import { trapFocus } from '../aria-helpers/trap-focus.aria'
import { Input } from '../components/Input'
import { Field } from './field'
@@ -23,6 +22,11 @@ import { Label } from '../components/Label'
import { $cerberusIcons } from '../config/defineIcons'
import { ModalIcon } from '../components/ModalIcon'
import { Show } from '../components/Show'
+import { useModal } from '../hooks/useModal'
+import { Modal } from '../components/Modal'
+import { ModalHeader } from '../components/ModalHeader'
+import { ModalHeading } from '../components/ModalHeading'
+import { ModalDescription } from '../components/ModalDescription'
/**
* This module provides a context and hook for the prompt modal.
@@ -78,11 +82,11 @@ export interface PromptModalProviderProps {}
export function PromptModal(
props: PropsWithChildren,
) {
- const dialogRef = useRef(null)
+ const { modalRef, show, close } = useModal()
const resolveRef = useRef(null)
const [content, setContent] = useState(null)
const [inputValue, setInputValue] = useState('')
- const focusTrap = trapFocus(dialogRef)
+ const focusTrap = trapFocus(modalRef)
const PromptIcon = $cerberusIcons.promptModal
const isValid = useMemo(
@@ -94,7 +98,6 @@ export function PromptModal(
() => (content?.kind === 'destructive' ? 'danger' : 'action'),
[content],
)
- const styles = modal()
const handleChange = useCallback(
(e: ChangeEvent) => {
@@ -109,18 +112,21 @@ export function PromptModal(
if (target.value === 'true') {
resolveRef.current?.(inputValue)
}
- dialogRef?.current?.close()
+ close()
},
- [inputValue],
+ [inputValue, close],
)
- const handleShow = useCallback((options: ShowPromptModalOptions) => {
- return new Promise((resolve) => {
- setContent({ ...options, kind: options.kind || 'non-destructive' })
- dialogRef?.current?.showModal()
- resolveRef.current = resolve
- })
- }, [])
+ const handleShow = useCallback(
+ (options: ShowPromptModalOptions) => {
+ return new Promise((resolve) => {
+ setContent({ ...options, kind: options.kind || 'non-destructive' })
+ show()
+ resolveRef.current = resolve
+ })
+ },
+ [show],
+ )
const value = useMemo(
() => ({
@@ -134,13 +140,8 @@ export function PromptModal(
{props.children}
-
-
+
+
- {content?.heading}
- {content?.description}
-
+ {content?.heading}
+ {content?.description}
+
-
+
)
diff --git a/packages/react/src/hooks/useModal.ts b/packages/react/src/hooks/useModal.ts
new file mode 100644
index 00000000..890c279f
--- /dev/null
+++ b/packages/react/src/hooks/useModal.ts
@@ -0,0 +1,34 @@
+'use client'
+
+import { useCallback, useMemo, useRef, type RefObject } from 'react'
+
+/**
+ * This module provides a hook for using a custom modal.
+ * @module
+ */
+
+interface UseModalReturnValue {
+ modalRef: RefObject
+ show: () => void
+ close: () => void
+}
+
+export function useModal(): UseModalReturnValue {
+ const modalRef = useRef(null)
+
+ const show = useCallback(() => {
+ modalRef.current?.showModal()
+ }, [])
+
+ const close = useCallback(() => {
+ modalRef.current?.close()
+ }, [])
+
+ return useMemo(() => {
+ return {
+ modalRef,
+ show,
+ close,
+ }
+ }, [modalRef, show, close])
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 67a88b45..9a1dbe9c 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -11,6 +11,10 @@ export * from './components/FeatureFlag'
export * from './components/IconButton'
export * from './components/Input'
export * from './components/Label'
+export * from './components/Modal'
+export * from './components/ModalHeader'
+export * from './components/ModalHeading'
+export * from './components/ModalDescription'
export * from './components/ModalIcon'
export * from './components/NavMenuTrigger'
export * from './components/NavMenuList'
@@ -37,6 +41,7 @@ export * from './context/theme'
// hooks
+export * from './hooks/useModal'
export * from './hooks/useTheme'
export * from './hooks/useToggle'
diff --git a/tests/react/hooks/useModal.test.tsx b/tests/react/hooks/useModal.test.tsx
new file mode 100644
index 00000000..7d8c3326
--- /dev/null
+++ b/tests/react/hooks/useModal.test.tsx
@@ -0,0 +1,43 @@
+import { describe, test, expect, afterEach, beforeEach } from 'bun:test'
+import { render, screen, cleanup } from '@testing-library/react'
+import { Modal, useModal } from '@cerberus-design/react'
+import { setupStrictMode } from '@/utils'
+import userEvent from '@testing-library/user-event'
+
+describe('useModal', () => {
+ setupStrictMode()
+
+ function Test() {
+ const { modalRef, show, close } = useModal()
+ return (
+
+
Show
+
+ Modal content
+ Close
+
+
+ )
+ }
+
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ afterEach(cleanup)
+
+ test('should show modal', async () => {
+ render( )
+ expect(screen.queryByRole('dialog')).toBeFalsy()
+ await userEvent.click(screen.getByText(/show/i))
+ expect(screen.getByRole('dialog')).toBeTruthy()
+ })
+
+ test('should close modal', async () => {
+ render( )
+ await userEvent.click(screen.getByText(/show/i))
+ expect(screen.getByRole('dialog')).toBeTruthy()
+ await userEvent.click(screen.getByText(/close/i))
+ expect(screen.queryByRole('dialog')).toBeFalsy()
+ })
+})