diff --git a/.changeset/healthy-worms-hear.md b/.changeset/healthy-worms-hear.md new file mode 100644 index 0000000..6f08390 --- /dev/null +++ b/.changeset/healthy-worms-hear.md @@ -0,0 +1,20 @@ +--- +"paris": minor +--- + +### pageConfig API +- Per-page configuration for title, bottomPanel, and additionalActions +- Declarative approach eliminates conditional logic in parent components +- Clean API for multi-step drawer flows + +### DrawerBottomPanelPortal Component +- Portal system for child components to control bottom panel content +- Eliminates need for prop drilling for components in separate files +- Automatic page-active detection in paginated drawers +- Supports replace, append, prepend modes + +### Context Providers +- useDrawer() - Access drawer controls from any child component +- usePaginationContext() - Access pagination state without prop drilling +- useDrawerBottomPanel() - Imperative bottom panel control +- useIsDrawerPageActive() - Internal page active tracking \ No newline at end of file diff --git a/.changeset/mighty-points-battle.md b/.changeset/mighty-points-battle.md new file mode 100644 index 0000000..15f3512 --- /dev/null +++ b/.changeset/mighty-points-battle.md @@ -0,0 +1,5 @@ +--- +"paris": patch +--- + +fix(Card): Update Card and Cardbutton styles diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/package.json b/package.json index bb3d974..227e050 100644 --- a/package.json +++ b/package.json @@ -141,4 +141,4 @@ "type-fest": "^3.10.0", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/src/stories/card/Card.module.scss b/src/stories/card/Card.module.scss index 9fd750b..60ab5fa 100644 --- a/src/stories/card/Card.module.scss +++ b/src/stories/card/Card.module.scss @@ -29,7 +29,7 @@ .flat { border-radius: var(--pte-new-borders-radius-roundedMedium); border: 1px solid var(--pte-new-colors-borderStrong); - background: linear-gradient(0deg, var(--pte-new-colors-overlayWhiteSubtle) 0%, var(--pte-new-colors-overlayWhiteSubtle) 100%), var(--pte-new-colors-surfacePrimary); + background: var(--pte-new-colors-overlayWhiteSubtle); &.pending { border: 1px dashed var(--pte-new-colors-borderStrong); diff --git a/src/stories/cardbutton/CardButton.module.scss b/src/stories/cardbutton/CardButton.module.scss index c09fc5d..4f7d4b4 100644 --- a/src/stories/cardbutton/CardButton.module.scss +++ b/src/stories/cardbutton/CardButton.module.scss @@ -66,7 +66,7 @@ .flat { border-radius: var(--pte-new-borders-radius-roundedMedium); border: 1px solid var(--pte-new-colors-borderStrong); - background: linear-gradient(0deg, var(--pte-new-colors-overlayWhiteSubtle) 0%, var(--pte-new-colors-overlayWhiteSubtle) 100%), var(--pte-new-colors-surfacePrimary); + background: var(--pte-new-colors-overlayWhiteSubtle); &:hover { background-color: var(--pte-new-colors-overlayStrong); diff --git a/src/stories/drawer/Drawer.stories.tsx b/src/stories/drawer/Drawer.stories.tsx index 8c71401..024020e 100644 --- a/src/stories/drawer/Drawer.stories.tsx +++ b/src/stories/drawer/Drawer.stories.tsx @@ -3,13 +3,16 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; import { Drawer } from './Drawer'; +import { DrawerBottomPanelPortal } from './DrawerBottomPanelPortal'; +import { useDrawer } from '.'; import { Button } from '../button'; import { Callout } from '../callout'; import { Menu, MenuButton, MenuItems, MenuItem, } from '../menu'; -import { usePagination } from '../pagination'; +import { usePagination, usePaginationContext } from '../pagination'; import { ChevronRight, Ellipsis } from '../icon'; +import { Input } from '../input'; const meta: Meta = { title: 'Surfaces/Drawer', @@ -278,3 +281,257 @@ export const Full: Story = { ); }, }; + +/** + * Example using pageConfig for declarative per-page configuration. + * Eliminates conditional logic in the parent component. + */ +export const WithPageConfig: Story = { + args: {}, + render: () => { + const [isOpen, setIsOpen] = useState(false); + const pages = ['details', 'edit', 'confirm'] as const; + const pagination = usePagination('details'); + + return ( + <> + + setIsOpen(false)} + title="Multi-Step Process" + pagination={pagination} + pageConfig={{ + details: { + title: 'View Details', + bottomPanel: ( + + ), + }, + edit: { + title: 'Edit Information', + bottomPanel: ( + + ), + }, + confirm: { + title: 'Confirm Changes', + bottomPanel: ( +
+ + +
+ ), + }, + }} + > +
+

Account Details

+

Name: John Doe

+

Email: john@example.com

+
+
+

Edit Account

+ + +
+
+

Confirm Your Changes

+ + Please review your changes before confirming. + +

Updated Name: John Doe

+

Updated Email: john@example.com

+
+
+ + ); + }, +}; + +/** + * Example using DrawerBottomPanelPortal to inject bottom panel content from a child component. + * Child component can control its own actions without prop drilling. + */ +export const WithBottomPanelPortal: Story = { + args: {}, + render: () => { + const [isOpen, setIsOpen] = useState(false); + + // Child component that uses the portal + const FormWithActions = () => { + const [value, setValue] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const { close } = useDrawer(); + + const handleSubmit = async () => { + setIsSubmitting(true); + // Simulate API call + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + setIsSubmitting(false); + close(); + }; + + return ( + <> +
+ setValue(e.target.value)} + placeholder="Type something..." + /> +

+ Your message: + {' '} + {value || '(empty)'} +

+
+ + +
+ + +
+
+ + ); + }; + + return ( + <> + + setIsOpen(false)} + title="Form Example" + > + + + + ); + }, +}; + +/** + * Example using usePaginationContext to access pagination from nested components. + * Eliminates prop drilling for navigation callbacks. + */ +export const WithPaginationContext: Story = { + args: {}, + render: () => { + const [isOpen, setIsOpen] = useState(false); + const pages = ['step1', 'step2', 'step3'] as const; + const pagination = usePagination('step1'); + + // Nested component that accesses pagination directly + const Step1Content = () => { + const paginationCtx = usePaginationContext(); + + return ( +
+

Step 1: Personal Information

+ + + + + +
+ ); + }; + + const Step2Content = () => { + const paginationCtx = usePaginationContext(); + + return ( +
+

Step 2: Contact Information

+ + + +
+ + +
+
+
+ ); + }; + + const Step3Content = () => { + const { close } = useDrawer(); + + return ( +
+

Step 3: Review & Submit

+ + Please review your information before submitting. + + + +
+ + +
+
+
+ ); + }; + + return ( + <> + + setIsOpen(false)} + title="Registration Wizard" + pagination={pagination} + pageConfig={{ + step1: { title: 'Step 1 of 3' }, + step2: { title: 'Step 2 of 3' }, + step3: { title: 'Step 3 of 3' }, + }} + > +
+
+
+
+ + ); + }, +}; diff --git a/src/stories/drawer/Drawer.tsx b/src/stories/drawer/Drawer.tsx index 561ce3b..806eba5 100644 --- a/src/stories/drawer/Drawer.tsx +++ b/src/stories/drawer/Drawer.tsx @@ -2,7 +2,7 @@ import type { ReactNode, ComponentPropsWithoutRef } from 'react'; import { - useMemo, useState, + useMemo, useState, useCallback, useEffect, } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, @@ -16,13 +16,230 @@ import { TextWhenString } from '../utility/TextWhenString'; import { Button } from '../button'; import { RemoveFromDOM } from '../utility/RemoveFromDOM'; import type { PaginationState } from '../pagination'; +import { PaginationProvider } from '../pagination/PaginationContext'; import { ChevronLeft, ChevronRight, Close, Icon, } from '../icon'; import { useResizeObserver } from '../../helpers/useResizeObserver'; +import { DrawerProvider } from './DrawerContext'; +import { DrawerBottomPanelProvider, useDrawerBottomPanel } from './DrawerBottomPanelContext'; +import { DrawerPageProvider } from './DrawerPageContext'; export const DrawerSizePresets = ['content', 'default', 'full', 'fullWithMargin', 'fullOnMobile'] as const; +/** + * Inner component that renders drawer content and accesses bottom panel context. + * Must be rendered within DrawerBottomPanelProvider. + * @internal + */ +type DrawerContentProps = { + isPaginated: boolean; + pagination?: PaginationState; + loadedPage: string | null; + setLoadedPage: (page: string | null) => void; + children: ReactNode; + currentPageConfig?: { + title?: ReactNode; + bottomPanel?: ReactNode; + additionalActions?: ReactNode; + }; + bottomPanel?: ReactNode; + resolvedTitle: ReactNode; + resolvedAdditionalActions?: ReactNode; + hasResolvedAdditionalActions: boolean; + hideTitle: boolean; + hideCloseButton: boolean; + onClose: (open: false) => void; + overrides?: DrawerProps['overrides']; +}; + +const DrawerContent = ({ + isPaginated, + pagination, + loadedPage, + setLoadedPage, + children, + currentPageConfig, + bottomPanel, + resolvedTitle, + resolvedAdditionalActions, + hasResolvedAdditionalActions, + hideTitle, + hideCloseButton, + onClose, + overrides, +}: DrawerContentProps) => { + const { portalContent } = useDrawerBottomPanel(); + + // Resolve bottom panel content with priority: + // 1. Portal content (highest priority) + // 2. pageConfig bottom panel + // 3. bottomPanel prop (fallback) + const resolvedBottomPanel = useMemo(() => { + if (portalContent) { + // Portal content exists - handle based on mode + const baseContent = currentPageConfig?.bottomPanel ?? bottomPanel; + + if (portalContent.mode === 'replace' || !baseContent) { + return portalContent.content; + } + if (portalContent.mode === 'prepend') { + return ( + <> + {portalContent.content} + {baseContent} + + ); + } + if (portalContent.mode === 'append') { + return ( + <> + {baseContent} + {portalContent.content} + + ); + } + } + + // No portal content - use pageConfig or prop + return currentPageConfig?.bottomPanel ?? bottomPanel; + }, [portalContent, currentPageConfig, bottomPanel]); + + return ( + <> + {/* Dialog title bar */} +
+
+ +
+ + +
+
+ + + + {resolvedTitle} + + + +
+
+ {/* Action Menu */} + + {resolvedAdditionalActions} + + + {/* Close button */} + + + +
+
+ +
+
+ {(isPaginated && Array.isArray(children)) ? children.map((child) => { + if (!(child && typeof child === 'object' && 'key' in child)) { + return null; + } + const isActive = child.key === pagination?.currentPage && loadedPage === child.key; + return ( + { + setLoadedPage(pagination?.currentPage || null); + }} + className={clsx(overrides?.contentChildrenChildren?.className)} + style={{ display: isActive ? undefined : 'none' }} + > + + {child} + + + ); + }) : children} +
+ {resolvedBottomPanel && ( + <> + +
+
+
+
+ {resolvedBottomPanel} +
+
+ + )} +
+ + ); +}; + export type DrawerProps = { /** * The dialog's open state. @@ -103,6 +320,38 @@ export type DrawerProps = { * @default false */ pagination?: PaginationState; + /** + * Per-page configuration for title, bottomPanel, and additionalActions. + * Only available when pagination is provided. + * + * Keys must match the pages in pagination state. + * Falls back to root-level props if not specified. + * + * @example + * ```tsx + * Next, + * }, + * step2: { + * title: 'Review', + * bottomPanel: , + * }, + * }} + * > + *
...
+ *
...
+ *
+ * ``` + */ + pageConfig?: Partial>; /** * The overlay style of the Drawer, either 'grey' or 'blur'. * @@ -152,6 +401,7 @@ export const Drawer = ({ from = 'right', size = 'default', pagination, + pageConfig, overlayStyle = 'grey', additionalActions, children, @@ -170,6 +420,44 @@ export const Drawer = ({ const [loadedPage, setLoadedPage] = useState(pagination?.history[0] || null); + // Update loadedPage when pagination changes + useEffect(() => { + if (pagination?.currentPage) { + setLoadedPage(pagination.currentPage); + } + }, [pagination?.currentPage]); + + // Get current page configuration + const currentPageConfig = useMemo(() => { + if (pagination && pageConfig) { + return pageConfig[pagination.currentPage]; + } + return undefined; + }, [pagination, pageConfig]); + + // Resolve title based on pageConfig or fallback to prop + const resolvedTitle = useMemo( + () => currentPageConfig?.title ?? title, + [currentPageConfig, title], + ); + + // Resolve additionalActions based on pageConfig or fallback to prop + const resolvedAdditionalActions = useMemo( + () => currentPageConfig?.additionalActions ?? additionalActions, + [currentPageConfig, additionalActions], + ); + + const hasResolvedAdditionalActions = useMemo( + () => Boolean(resolvedAdditionalActions), + [resolvedAdditionalActions], + ); + + // Create drawer context value + const drawerContextValue = useMemo(() => ({ + isOpen, + close: () => onClose(false), + }), [isOpen, onClose]); + // const bottomPanelRef = useRef(null); // const { width = 0, height = 0 } = useResizeObserver({ // ref: bottomPanelRef, @@ -180,30 +468,35 @@ export const Drawer = ({ // console.log(bottomPanelRef.current); // }, [bottomPanelRef.current]); - // Decide what children to render. - const currentChild: ReactNode = useMemo(() => { - // If no children are provided, render nothing. - if (!children) { - return (<>); - } + // Wrap content with providers + const wrappedContent = (content: ReactNode) => { + let wrapped = content; - // If pagination is enabled, and multiple children are provided, render the currently active child by matching its key against `pagination.currentPage`. - if (pagination && Array.isArray(children) && children.length > 0) { - const found = children.find((child) => { - if (!(child && typeof child === 'object' && 'key' in child)) { - console.warn('Drawer: Pagination is enabled, but the following child is missing a `key` prop. Pagination will likely not work as expected and this child will never be rendered.', child); - return false; - } - return child.key === pagination.currentPage; - }); - if (found) { - return found; - } + // Wrap with bottom panel provider (always, even without pagination) + wrapped = ( + + {wrapped} + + ); + + // Wrap with pagination provider if pagination is enabled + if (pagination) { + wrapped = ( + + {wrapped} + + ); } - // As a fallback, render all children. - return children; - }, [children, pagination]); + // Wrap with drawer provider (always) + wrapped = ( + + {wrapped} + + ); + + return wrapped; + }; return ( @@ -270,125 +563,25 @@ export const Drawer = ({ overrides?.panel?.className, )} > - {/* Dialog title bar */} -
-
- -
- - -
-
- - - - {title} - - - -
-
- {/* Action Menu */} - - {additionalActions} - - - {/* Close button */} - - - -
-
- -
-
- {(isPaginated && Array.isArray(children)) ? children.map((child) => (child && typeof child === 'object' && 'key' in child) && ( - { - setLoadedPage(pagination?.currentPage || null); - }} - className={clsx(overrides?.contentChildrenChildren?.className)} - > - {child} - - )) : children} -
- {bottomPanel && ( - <> - -
-
-
-
- {bottomPanel} -
-
- - )} -
+ {children} + , + )}
diff --git a/src/stories/drawer/DrawerBottomPanelContext.tsx b/src/stories/drawer/DrawerBottomPanelContext.tsx new file mode 100644 index 0000000..cf7ffcd --- /dev/null +++ b/src/stories/drawer/DrawerBottomPanelContext.tsx @@ -0,0 +1,146 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { + createContext, useContext, useState, useCallback, useMemo, +} from 'react'; + +/** + * Mode for how portal content should interact with existing bottom panel content. + */ +export type BottomPanelMode = 'replace' | 'append' | 'prepend'; + +/** + * Portal content with its rendering mode. + */ +export interface PortalContent { + content: ReactNode; + mode: BottomPanelMode; +} + +/** + * Context value for bottom panel controls. + */ +export interface DrawerBottomPanelContextValue { + /** + * Current portal content, if any. + */ + portalContent: PortalContent | null; + /** + * Set portal content with specified mode. + */ + setPortalContent: (content: ReactNode | null, mode?: BottomPanelMode) => void; +} + +/** + * Context for drawer bottom panel portal system. + */ +const DrawerBottomPanelContext = createContext(null); + +export type DrawerBottomPanelProviderProps = { + children: ReactNode; +}; + +/** + * Provider for drawer bottom panel portal system. Used internally by the Drawer component. + * + * @internal + */ +export const DrawerBottomPanelProvider = ({ children }: DrawerBottomPanelProviderProps) => { + const [portalContent, setPortalContentState] = useState(null); + + const setPortalContent = useCallback((content: ReactNode | null, mode: BottomPanelMode = 'replace') => { + if (content === null) { + setPortalContentState(null); + } else { + setPortalContentState({ content, mode }); + } + }, []); + + const value: DrawerBottomPanelContextValue = useMemo(() => ({ + portalContent, + setPortalContent, + }), [portalContent, setPortalContent]); + + return ( + + {children} + + ); +}; + +/** + * Access drawer bottom panel controls from any child component within a Drawer. + * Throws error if used outside a Drawer. + * + * This hook provides imperative control over the drawer's bottom panel content, + * useful for conditional logic or callback-based rendering. + * + * @example + * ```tsx + * const DynamicForm = () => { + * const { setPortalContent, clearPortalContent } = useDrawerBottomPanel(); + * const [advanced, setAdvanced] = useState(false); + * + * useEffect(() => { + * if (advanced) { + * setPortalContent(); + * } else { + * setPortalContent(); + * } + * return () => clearPortalContent(); + * }, [advanced]); + * + * return
...
; + * }; + * ``` + * + * @throws Error if used outside a Drawer component + * @returns Controls for the bottom panel portal + */ +export const useDrawerBottomPanel = () => { + const context = useContext(DrawerBottomPanelContext); + + if (!context) { + throw new Error('useDrawerBottomPanel must be used within a Drawer component'); + } + + const clearPortalContent = useCallback(() => { + context.setPortalContent(null); + }, [context]); + + return { + /** + * Current portal content, if any. + * @internal + */ + portalContent: context.portalContent, + /** + * Set the bottom panel content, replacing any existing content. + */ + setBottomPanel: useCallback((content: ReactNode) => { + context.setPortalContent(content, 'replace'); + }, [context]), + /** + * Add content after existing bottom panel content. + */ + appendBottomPanel: useCallback((content: ReactNode) => { + context.setPortalContent(content, 'append'); + }, [context]), + /** + * Add content before existing bottom panel content. + */ + prependBottomPanel: useCallback((content: ReactNode) => { + context.setPortalContent(content, 'prepend'); + }, [context]), + /** + * Clear all portal-injected content. + */ + clearBottomPanel: clearPortalContent, + /** + * Set portal content with specified mode (internal use). + * @internal + */ + setPortalContent: context.setPortalContent, + }; +}; diff --git a/src/stories/drawer/DrawerBottomPanelPortal.tsx b/src/stories/drawer/DrawerBottomPanelPortal.tsx new file mode 100644 index 0000000..3d422a6 --- /dev/null +++ b/src/stories/drawer/DrawerBottomPanelPortal.tsx @@ -0,0 +1,104 @@ +'use client'; + +import type { FC, ReactNode } from 'react'; +import { useEffect } from 'react'; +import type { BottomPanelMode } from './DrawerBottomPanelContext'; +import { useDrawerBottomPanel } from './DrawerBottomPanelContext'; +import { useIsDrawerPageActive } from './DrawerPageContext'; + +export type DrawerBottomPanelPortalProps = { + /** + * The content to render in the drawer's bottom panel. + */ + children: ReactNode; + /** + * How to combine with existing bottom panel content: + * - 'replace': Replace entire bottom panel (default) + * - 'append': Add after existing content + * - 'prepend': Add before existing content + * + * @default 'replace' + */ + mode?: BottomPanelMode; + /** + * Only activate portal when this condition is true. + * Useful for pagination where multiple portals exist but only one should be active. + * + * @default true + */ + when?: boolean; +}; + +/** + * Portal component to render content into the drawer's bottom panel. + * Can be used anywhere within a Drawer's children tree. + * + * This provides a clean way for child components to inject content + * into the drawer's bottom panel without prop drilling, which is especially + * useful when page components are defined in separate files. + * + * **Pagination Support:** + * When used with paginated drawers, the portal automatically detects which + * page is active and only renders content from the currently visible page. + * All pages remain mounted (for animation purposes), but only the active + * page's portal content is displayed. + * + * @example + * // Separate component file + * const SaveForm = () => { + * const handleSubmit = async () => { ... }; + * const [isSubmitting, setIsSubmitting] = useState(false); + * + * return ( + * <> + *
+ * + * + *
+ * + * + * + * + * + * ); + * }; + * + * // In parent component + * + *
+ *
+ *
+ * + * @throws Error if used outside a Drawer component + */ +export const DrawerBottomPanelPortal: FC = ({ + children, + mode = 'replace', + when, +}) => { + const { setPortalContent } = useDrawerBottomPanel(); + const isPageActive = useIsDrawerPageActive(); + + // Use explicit when prop if provided, otherwise use page active state + const isActive = when !== undefined ? when : isPageActive; + + useEffect(() => { + if (!isActive) { + // Don't set content when inactive + return undefined; + } + + // Set content when active + setPortalContent(children, mode); + + // Clear when this effect re-runs or component unmounts + return () => { + setPortalContent(null); + }; + }, [children, mode, isActive, setPortalContent]); + + // Renders nothing in place - content is portaled to bottom panel + return null; +}; diff --git a/src/stories/drawer/DrawerContext.tsx b/src/stories/drawer/DrawerContext.tsx new file mode 100644 index 0000000..fe5ded2 --- /dev/null +++ b/src/stories/drawer/DrawerContext.tsx @@ -0,0 +1,80 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +/** + * Context value provided by DrawerProvider. + */ +export interface DrawerContextValue { + /** + * Whether the drawer is currently open. + */ + isOpen: boolean; + /** + * Close the drawer. Calls the onClose callback passed to the Drawer component. + */ + close: () => void; +} + +/** + * Context for drawer state, allowing child components to access drawer controls + * without prop drilling. + */ +const DrawerContext = createContext(null); + +export type DrawerProviderProps = { + /** + * The drawer context value. + */ + value: DrawerContextValue; + /** + * Child components that can access drawer controls via {@link useDrawer}. + */ + children: ReactNode; +}; + +/** + * Provider for drawer context. Used internally by the Drawer component. + * + * @internal + */ +export const DrawerProvider = ({ value, children }: DrawerProviderProps) => ( + + {children} + +); + +/** + * Access drawer state from any child component within a Drawer. + * Throws error if used outside a Drawer. + * + * This hook allows child components to close the drawer or check its open state + * without requiring callbacks to be passed through props. + * + * @example + * ```tsx + * const SuccessMessage = () => { + * const { close, isOpen } = useDrawer(); + * + * const handleDone = () => { + * // Perform actions + * close(); + * }; + * + * return ; + * }; + * ``` + * + * @throws Error if used outside a Drawer component + * @returns The drawer context value + */ +export const useDrawer = (): DrawerContextValue => { + const context = useContext(DrawerContext); + + if (!context) { + throw new Error('useDrawer must be used within a Drawer component'); + } + + return context; +}; diff --git a/src/stories/drawer/DrawerPageContext.tsx b/src/stories/drawer/DrawerPageContext.tsx new file mode 100644 index 0000000..f246843 --- /dev/null +++ b/src/stories/drawer/DrawerPageContext.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +/** + * Context to track if the current drawer page is active. + * Used internally for pagination to control portal behavior. + * @internal + */ +const DrawerPageContext = createContext(true); + +export type DrawerPageProviderProps = { + /** + * Whether this page is currently active/visible. + */ + isActive: boolean; + /** + * Child components. + */ + children: ReactNode; +}; + +/** + * Provider to indicate if a drawer page is currently active. + * Used internally by Drawer for paginated content. + * @internal + */ +export const DrawerPageProvider = ({ isActive, children }: DrawerPageProviderProps) => ( + + {children} + +); + +/** + * Hook to check if the current drawer page is active. + * Returns true if not within a paginated drawer. + * @internal + */ +export const useIsDrawerPageActive = (): boolean => useContext(DrawerPageContext); diff --git a/src/stories/drawer/index.ts b/src/stories/drawer/index.ts index 0529d64..a0fc35b 100644 --- a/src/stories/drawer/index.ts +++ b/src/stories/drawer/index.ts @@ -1 +1,5 @@ export * from './Drawer'; +export * from './DrawerContext'; +export * from './DrawerBottomPanelContext'; +export * from './DrawerBottomPanelPortal'; +export * from './DrawerPageContext'; diff --git a/src/stories/pagination/PaginationContext.tsx b/src/stories/pagination/PaginationContext.tsx new file mode 100644 index 0000000..b99eb96 --- /dev/null +++ b/src/stories/pagination/PaginationContext.tsx @@ -0,0 +1,69 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; +import type { PaginationState } from './usePagination'; + +/** + * Context for pagination state, allowing child components to access pagination + * controls without prop drilling. + */ +const PaginationContext = createContext | null>(null); + +export type PaginationProviderProps = { + /** + * The pagination state from {@link usePagination}. + */ + value: PaginationState; + /** + * Child components that can access pagination via {@link usePaginationContext}. + */ + children: ReactNode; +}; + +/** + * Provider for pagination context. Automatically used by Drawer when pagination prop is passed. + * Can also be used standalone for custom pagination implementations. + * + * @example + * ```tsx + * const pagination = usePagination(['step1', 'step2', 'step3'] as const, 'step1'); + * + * + * + * + * ``` + */ +export const PaginationProvider = ({ + value, + children, +}: PaginationProviderProps) => ( + + {children} + +); + +/** + * Access pagination state from any child component within a PaginationProvider. + * Returns null if not within a pagination context. + * + * This hook allows child components to navigate between pages without requiring + * pagination callbacks to be passed through props. + * + * @example + * ```tsx + * const SaveForm = () => { + * const pagination = usePaginationContext<['form', 'review', 'complete']>(); + * + * const handleSave = async () => { + * await save(); + * pagination?.open('review'); + * }; + * + * return ; + * }; + * ``` + * + * @returns The pagination state, or null if not within a PaginationProvider + */ +export const usePaginationContext = (): PaginationState | null => useContext(PaginationContext) as PaginationState | null; diff --git a/src/stories/pagination/index.ts b/src/stories/pagination/index.ts index 612e4d2..8649549 100644 --- a/src/stories/pagination/index.ts +++ b/src/stories/pagination/index.ts @@ -1 +1,2 @@ export * from './usePagination'; +export * from './PaginationContext';