From da1bfaf17072f5ea2f6d1a8336dd700089d146a0 Mon Sep 17 00:00:00 2001 From: Matheus Verissimo Date: Sun, 7 Jul 2024 18:19:53 -0300 Subject: [PATCH] feat(react): add floating panel --- .../.storybook/styles/floating-panel.css | 90 +++++++++++++++ .../floating-panel/examples/basic.tsx | 59 ++++++++++ .../floating-panel/examples/controlled.tsx | 66 +++++++++++ .../floating-panel/examples/lazy-mount.tsx | 61 ++++++++++ .../floating-panel/examples/render-fn.tsx | 65 +++++++++++ .../floating-panel/floating-panel-body.tsx | 18 +++ .../floating-panel-close-trigger.tsx | 21 ++++ .../floating-panel/floating-panel-content.tsx | 30 +++++ .../floating-panel/floating-panel-context.tsx | 9 ++ .../floating-panel/floating-panel-dock.tsx | 13 +++ .../floating-panel-drag-trigger.tsx | 20 ++++ .../floating-panel/floating-panel-header.tsx | 18 +++ .../floating-panel-maximize-trigger.tsx | 21 ++++ .../floating-panel-minimize-trigger.tsx | 21 ++++ .../floating-panel-positioner.tsx | 26 +++++ .../floating-panel-resize-trigger.tsx | 24 ++++ .../floating-panel-restore-trigger.tsx | 21 ++++ .../floating-panel-root-provider.tsx | 31 ++++++ .../floating-panel/floating-panel-root.tsx | 28 +++++ .../floating-panel/floating-panel-title.tsx | 18 +++ .../floating-panel/floating-panel-trigger.tsx | 30 +++++ .../floating-panel/floating-panel.anatomy.ts | 1 + .../floating-panel/floating-panel.stories.tsx | 12 ++ .../floating-panel/floating-panel.ts | 76 +++++++++++++ .../src/components/floating-panel/index.ts | 89 +++++++++++++++ .../components/floating-panel/tests/basic.tsx | 32 ++++++ .../tests/floating-panel.spec.tsx | 105 ++++++++++++++++++ .../use-floating-panel-context.ts | 11 ++ .../floating-panel/use-floating-panel.ts | 40 +++++++ packages/react/src/components/index.ts | 1 + 30 files changed, 1057 insertions(+) create mode 100644 packages/react/.storybook/styles/floating-panel.css create mode 100644 packages/react/src/components/floating-panel/examples/basic.tsx create mode 100644 packages/react/src/components/floating-panel/examples/controlled.tsx create mode 100644 packages/react/src/components/floating-panel/examples/lazy-mount.tsx create mode 100644 packages/react/src/components/floating-panel/examples/render-fn.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-body.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-close-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-content.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-context.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-dock.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-drag-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-header.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-maximize-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-minimize-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-positioner.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-resize-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-restore-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-root-provider.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-root.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-title.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel-trigger.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel.anatomy.ts create mode 100644 packages/react/src/components/floating-panel/floating-panel.stories.tsx create mode 100644 packages/react/src/components/floating-panel/floating-panel.ts create mode 100644 packages/react/src/components/floating-panel/index.ts create mode 100644 packages/react/src/components/floating-panel/tests/basic.tsx create mode 100644 packages/react/src/components/floating-panel/tests/floating-panel.spec.tsx create mode 100644 packages/react/src/components/floating-panel/use-floating-panel-context.ts create mode 100644 packages/react/src/components/floating-panel/use-floating-panel.ts diff --git a/packages/react/.storybook/styles/floating-panel.css b/packages/react/.storybook/styles/floating-panel.css new file mode 100644 index 0000000000..db6de978e9 --- /dev/null +++ b/packages/react/.storybook/styles/floating-panel.css @@ -0,0 +1,90 @@ +[data-scope='floating-panel'] button { + appearance: none; + border: 1px solid black; + margin: 0; + padding: 0; +} +[data-scope='floating-panel'][data-part='content'] { + box-shadow: + rgba(0, 0, 0, 0.28) 0px 16px 18px 0px, + rgba(0, 0, 0, 0.12) 0px 4px 16px 0px; + outline: 0 !important; + background-color: white; + display: flex; + flex-direction: column; + + &[data-topmost] { + z-index: 999999; + } + + &[data-behind] { + opacity: 0.4; + } +} + +[data-scope='floating-panel'][data-part='body'] { + position: relative; + overflow: auto; + flex: 1 1 auto; + padding-block: 16px; + padding-inline: 16px; + background-color: white; +} + +[data-scope='floating-panel'][data-part='header'] { + padding-block: 16px; + padding-inline: 16px; + background-color: #f5f5f5; + border-bottom: 1px solid #ebebeb; + display: flex; + justify-content: space-between; + align-items: center; +} + +[data-scope='floating-panel'][data-part='header'] button { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + padding: 0; + svg { + width: 1em; + height: 1em; + } +} + +[data-scope='floating-panel'][data-part='trigger-group'] { + display: flex; + align-items: center; + gap: 8px; +} + +[data-scope='floating-panel'][data-part='resize-trigger'] { + background-color: rgba(154, 18, 18, 0.396); + + &[data-axis='n'], + &[data-axis='s'] { + height: 6px; + max-width: 90%; + } + + &[data-axis='e'], + &[data-axis='w'] { + width: 6px; + max-height: 90%; + } + + &[data-axis='ne'], + &[data-axis='nw'], + &[data-axis='se'], + &[data-axis='sw'] { + width: 10px; + height: 10px; + } +} + +[hidden] { + display: none !important; +} diff --git a/packages/react/src/components/floating-panel/examples/basic.tsx b/packages/react/src/components/floating-panel/examples/basic.tsx new file mode 100644 index 0000000000..de8cb1c560 --- /dev/null +++ b/packages/react/src/components/floating-panel/examples/basic.tsx @@ -0,0 +1,59 @@ +import { + FloatingPanel, + FloatingPanelBody, + FloatingPanelCloseTrigger, + FloatingPanelContent, + FloatingPanelDragTrigger, + FloatingPanelHeader, + FloatingPanelMaximizeTrigger, + FloatingPanelMinimizeTrigger, + FloatingPanelPositioner, + FloatingPanelResizeTrigger, + FloatingPanelRestoreTrigger, + FloatingPanelTitle, + FloatingPanelTrigger, + Portal, +} from '../..' + +import { ArrowDownLeft, Maximize2, Minus, XIcon } from 'lucide-react' + +export const Basic = () => ( + + Toggle Panel + + + + + Floating Panel +
+ + + + + + + + + + + + +
+
+
+ +

Some content

+
+ + + + + + + + + +
+
+
+) diff --git a/packages/react/src/components/floating-panel/examples/controlled.tsx b/packages/react/src/components/floating-panel/examples/controlled.tsx new file mode 100644 index 0000000000..e36994996f --- /dev/null +++ b/packages/react/src/components/floating-panel/examples/controlled.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { + FloatingPanel, + FloatingPanelBody, + FloatingPanelCloseTrigger, + FloatingPanelContent, + FloatingPanelDragTrigger, + FloatingPanelHeader, + FloatingPanelMaximizeTrigger, + FloatingPanelMinimizeTrigger, + FloatingPanelPositioner, + FloatingPanelResizeTrigger, + FloatingPanelRestoreTrigger, + FloatingPanelTitle, + FloatingPanelTrigger, + Portal, +} from '../..' + +import { ArrowDownLeft, Maximize2, Minus, XIcon } from 'lucide-react' + +export const Controlled = () => { + const [isOpen, setIsOpen] = useState(false) + + return ( + setIsOpen(e.open)}> + setIsOpen(true)}>Toggle Panel + + + + + + Floating Panel +
+ + + + + + + + + + + + +
+
+
+ +

Some content

+
+ + + + + + + + + +
+
+
+
+ ) +} diff --git a/packages/react/src/components/floating-panel/examples/lazy-mount.tsx b/packages/react/src/components/floating-panel/examples/lazy-mount.tsx new file mode 100644 index 0000000000..9163ee63c2 --- /dev/null +++ b/packages/react/src/components/floating-panel/examples/lazy-mount.tsx @@ -0,0 +1,61 @@ +import { + FloatingPanel, + FloatingPanelBody, + FloatingPanelCloseTrigger, + FloatingPanelContent, + FloatingPanelDragTrigger, + FloatingPanelHeader, + FloatingPanelMaximizeTrigger, + FloatingPanelMinimizeTrigger, + FloatingPanelPositioner, + FloatingPanelResizeTrigger, + FloatingPanelRestoreTrigger, + FloatingPanelTitle, + FloatingPanelTrigger, + Portal, +} from '../..' + +import { ArrowDownLeft, Maximize2, Minus, XIcon } from 'lucide-react' + +export const LazyMount = () => ( + console.log('onExitComplete invoked')}> + Toggle Panel + + + + + + Floating Panel +
+ + + + + + + + + + + + +
+
+
+ +

Some content

+
+ + + + + + + + + +
+
+
+
+) diff --git a/packages/react/src/components/floating-panel/examples/render-fn.tsx b/packages/react/src/components/floating-panel/examples/render-fn.tsx new file mode 100644 index 0000000000..c525e52d98 --- /dev/null +++ b/packages/react/src/components/floating-panel/examples/render-fn.tsx @@ -0,0 +1,65 @@ +import { + FloatingPanel, + FloatingPanelBody, + FloatingPanelCloseTrigger, + FloatingPanelContent, + FloatingPanelContext, + FloatingPanelDragTrigger, + FloatingPanelHeader, + FloatingPanelMaximizeTrigger, + FloatingPanelMinimizeTrigger, + FloatingPanelPositioner, + FloatingPanelResizeTrigger, + FloatingPanelRestoreTrigger, + FloatingPanelTitle, + FloatingPanelTrigger, + Portal, +} from '../..' + +import { ArrowDownLeft, Maximize2, Minus, XIcon } from 'lucide-react' + +export const RenderFn = () => ( + + Toggle Panel + + + + + + Floating Panel +
+ + + + + + + + + + + + +
+
+
+ +

Some content

+
+ + + + + + + + + +
+
+
+ + {(floatingPanel) =>

floatingPanel is {floatingPanel.open ? 'open' : 'closed'}

} +
+
+) diff --git a/packages/react/src/components/floating-panel/floating-panel-body.tsx b/packages/react/src/components/floating-panel/floating-panel-body.tsx new file mode 100644 index 0000000000..d96de2af92 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-body.tsx @@ -0,0 +1,18 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelBodyBaseProps extends PolymorphicProps {} +export interface FloatingPanelBodyProps extends HTMLProps<'div'>, FloatingPanelBodyBaseProps {} + +export const FloatingPanelBody = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getBodyProps(), props) + + return + }, +) + +FloatingPanelBody.displayName = 'FloatingPanelBody' diff --git a/packages/react/src/components/floating-panel/floating-panel-close-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-close-trigger.tsx new file mode 100644 index 0000000000..90f22b54d3 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-close-trigger.tsx @@ -0,0 +1,21 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelCloseTriggerBaseProps extends PolymorphicProps {} +export interface FloatingPanelCloseTriggerProps + extends HTMLProps<'button'>, + FloatingPanelCloseTriggerBaseProps {} + +export const FloatingPanelCloseTrigger = forwardRef< + HTMLButtonElement, + FloatingPanelCloseTriggerProps +>((props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getCloseTriggerProps(), props) + + return +}) + +FloatingPanelCloseTrigger.displayName = 'FloatingPanelCloseTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel-content.tsx b/packages/react/src/components/floating-panel/floating-panel-content.tsx new file mode 100644 index 0000000000..3b16fdf84e --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-content.tsx @@ -0,0 +1,30 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { usePresenceContext } from '../presence' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelContentBaseProps extends PolymorphicProps {} +export interface FloatingPanelContentProps + extends HTMLProps<'div'>, + FloatingPanelContentBaseProps {} + +export const FloatingPanelContent = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const presence = usePresenceContext() + const mergedProps = mergeProps( + floatingPanel.getContentProps(), + presence.getPresenceProps(ref), + props, + ) + + if (presence.unmounted) { + return null + } + + return + }, +) + +FloatingPanelContent.displayName = 'FloatingPanelContent' diff --git a/packages/react/src/components/floating-panel/floating-panel-context.tsx b/packages/react/src/components/floating-panel/floating-panel-context.tsx new file mode 100644 index 0000000000..aea516408e --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-context.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' +import { type UseFloatingPanelContext, useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelContextProps { + children: (context: UseFloatingPanelContext) => ReactNode +} + +export const FloatingPanelContext = (props: FloatingPanelContextProps) => + props.children(useFloatingPanelContext()) diff --git a/packages/react/src/components/floating-panel/floating-panel-dock.tsx b/packages/react/src/components/floating-panel/floating-panel-dock.tsx new file mode 100644 index 0000000000..50d72c1851 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-dock.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' + +export interface FloatingPanelDockBaseProps extends PolymorphicProps {} +export interface FloatingPanelDockProps extends HTMLProps<'div'>, FloatingPanelDockBaseProps {} + +export const FloatingPanelDock = forwardRef( + (props, ref) => { + return + }, +) + +FloatingPanelDock.displayName = 'FloatingPanelDock' diff --git a/packages/react/src/components/floating-panel/floating-panel-drag-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-drag-trigger.tsx new file mode 100644 index 0000000000..549eb9d6c7 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-drag-trigger.tsx @@ -0,0 +1,20 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelDragTriggerBaseProps extends PolymorphicProps {} +export interface FloatingPanelDragTriggerProps + extends HTMLProps<'div'>, + FloatingPanelDragTriggerBaseProps {} + +export const FloatingPanelDragTrigger = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getDragTriggerProps(), props) + + return + }, +) + +FloatingPanelDragTrigger.displayName = 'FloatingPanelDragTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel-header.tsx b/packages/react/src/components/floating-panel/floating-panel-header.tsx new file mode 100644 index 0000000000..483095ff67 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-header.tsx @@ -0,0 +1,18 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelHeaderBaseProps extends PolymorphicProps {} +export interface FloatingPanelHeaderProps extends HTMLProps<'div'>, FloatingPanelHeaderBaseProps {} + +export const FloatingPanelHeader = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getHeaderProps(), props) + + return + }, +) + +FloatingPanelHeader.displayName = 'FloatingPanelHeader' diff --git a/packages/react/src/components/floating-panel/floating-panel-maximize-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-maximize-trigger.tsx new file mode 100644 index 0000000000..a1c1d38378 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-maximize-trigger.tsx @@ -0,0 +1,21 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelMaximizeTriggerBaseProps extends PolymorphicProps {} +export interface FloatingPanelMaximizeTriggerProps + extends HTMLProps<'button'>, + FloatingPanelMaximizeTriggerBaseProps {} + +export const FloatingPanelMaximizeTrigger = forwardRef< + HTMLButtonElement, + FloatingPanelMaximizeTriggerProps +>((props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getMaximizeTriggerProps(), props) + + return +}) + +FloatingPanelMaximizeTrigger.displayName = 'FloatingPanelMaximizeTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel-minimize-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-minimize-trigger.tsx new file mode 100644 index 0000000000..fc3d3a2f17 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-minimize-trigger.tsx @@ -0,0 +1,21 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelMinimizeTriggerBaseProps extends PolymorphicProps {} +export interface FloatingPanelMinimizeTriggerProps + extends HTMLProps<'button'>, + FloatingPanelMinimizeTriggerBaseProps {} + +export const FloatingPanelMinimizeTrigger = forwardRef< + HTMLButtonElement, + FloatingPanelMinimizeTriggerProps +>((props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getMinimizeTriggerProps(), props) + + return +}) + +FloatingPanelMinimizeTrigger.displayName = 'FloatingPanelMinimizeTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel-positioner.tsx b/packages/react/src/components/floating-panel/floating-panel-positioner.tsx new file mode 100644 index 0000000000..bb95a2bfb8 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-positioner.tsx @@ -0,0 +1,26 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { usePresenceContext } from '../presence' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelPositionerBaseProps extends PolymorphicProps {} +export interface FloatingPanelPositionerProps + extends HTMLProps<'div'>, + FloatingPanelPositionerBaseProps {} + +export const FloatingPanelPositioner = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getPositionerProps(), props) + const presence = usePresenceContext() + + if (presence.unmounted) { + return null + } + + return + }, +) + +FloatingPanelPositioner.displayName = 'FloatingPanelPositioner' diff --git a/packages/react/src/components/floating-panel/floating-panel-resize-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-resize-trigger.tsx new file mode 100644 index 0000000000..da7416f641 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-resize-trigger.tsx @@ -0,0 +1,24 @@ +import type { ResizeTriggerProps } from '@zag-js/floating-panel' +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { createSplitProps } from '../../utils/create-split-props' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelResizeTriggerBaseProps extends ResizeTriggerProps, PolymorphicProps {} +export interface FloatingPanelResizeTriggerProps + extends HTMLProps<'button'>, + FloatingPanelResizeTriggerBaseProps {} + +export const FloatingPanelResizeTrigger = forwardRef< + HTMLButtonElement, + FloatingPanelResizeTriggerProps +>((props, ref) => { + const [resizeProps, localProps] = createSplitProps()(props, ['axis']) + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getResizeTriggerProps(resizeProps), localProps) + + return +}) + +FloatingPanelResizeTrigger.displayName = 'FloatingPanelResizeTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel-restore-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-restore-trigger.tsx new file mode 100644 index 0000000000..1448c278e3 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-restore-trigger.tsx @@ -0,0 +1,21 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelRestoreTriggerBaseProps extends PolymorphicProps {} +export interface FloatingPanelRestoreTriggerProps + extends HTMLProps<'button'>, + FloatingPanelRestoreTriggerBaseProps {} + +export const FloatingPanelRestoreTrigger = forwardRef< + HTMLButtonElement, + FloatingPanelRestoreTriggerProps +>((props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getRestoreTriggerProps(), props) + + return +}) + +FloatingPanelRestoreTrigger.displayName = 'FloatingPanelRestoreTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel-root-provider.tsx b/packages/react/src/components/floating-panel/floating-panel-root-provider.tsx new file mode 100644 index 0000000000..76d1a0a151 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-root-provider.tsx @@ -0,0 +1,31 @@ +import { mergeProps } from '@zag-js/react' +import type { ReactNode } from 'react' +import { RenderStrategyPropsProvider, splitRenderStrategyProps } from '../../utils/render-strategy' +import type { UsePresenceProps } from '../presence' +import { PresenceProvider, usePresence } from '../presence' +import { splitPresenceProps } from '../presence/split-presence-props' +import type { UseFloatingPanelReturn } from './use-floating-panel' +import { FloatingPanelProvider } from './use-floating-panel-context' + +interface RootProviderProps { + value: UseFloatingPanelReturn +} + +export interface FloatingPanelRootProviderBaseProps extends RootProviderProps, UsePresenceProps {} +export interface FloatingPanelRootProviderProps extends FloatingPanelRootProviderBaseProps { + children?: ReactNode +} + +export const FloatingPanelRootProvider = (props: FloatingPanelRootProviderProps) => { + const [presenceProps, { value: floatingPanel, children }] = splitPresenceProps(props) + const [renderStrategyProps] = splitRenderStrategyProps(presenceProps) + const presence = usePresence(mergeProps({ present: floatingPanel.open }, presenceProps)) + + return ( + + + {children} + + + ) +} diff --git a/packages/react/src/components/floating-panel/floating-panel-root.tsx b/packages/react/src/components/floating-panel/floating-panel-root.tsx new file mode 100644 index 0000000000..f36a80282e --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-root.tsx @@ -0,0 +1,28 @@ +import { mergeProps } from '@zag-js/react' +import type { ReactNode } from 'react' +import { RenderStrategyPropsProvider, splitRenderStrategyProps } from '../../utils/render-strategy' +import type { UsePresenceProps } from '../presence' +import { PresenceProvider, usePresence } from '../presence' +import { splitPresenceProps } from '../presence/split-presence-props' +import { type UseFloatingPanelProps, useFloatingPanel } from './use-floating-panel' +import { FloatingPanelProvider } from './use-floating-panel-context' + +export interface FloatingPanelRootBaseProps extends UseFloatingPanelProps, UsePresenceProps {} +export interface FloatingPanelRootProps extends FloatingPanelRootBaseProps { + children?: ReactNode +} + +export const FloatingPanelRoot = (props: FloatingPanelRootProps) => { + const [presenceProps, { children, ...localProps }] = splitPresenceProps(props) + const [renderStrategyProps] = splitRenderStrategyProps(presenceProps) + const floatingPanel = useFloatingPanel(localProps) + const presence = usePresence(mergeProps({ present: floatingPanel.open }, presenceProps)) + + return ( + + + {children} + + + ) +} diff --git a/packages/react/src/components/floating-panel/floating-panel-title.tsx b/packages/react/src/components/floating-panel/floating-panel-title.tsx new file mode 100644 index 0000000000..9e5af68646 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-title.tsx @@ -0,0 +1,18 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelTitleBaseProps extends PolymorphicProps {} +export interface FloatingPanelTitleProps extends HTMLProps<'p'>, FloatingPanelTitleBaseProps {} + +export const FloatingPanelTitle = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const mergedProps = mergeProps(floatingPanel.getTitleProps(), props) + + return + }, +) + +FloatingPanelTitle.displayName = 'FloatingPanelTitle' diff --git a/packages/react/src/components/floating-panel/floating-panel-trigger.tsx b/packages/react/src/components/floating-panel/floating-panel-trigger.tsx new file mode 100644 index 0000000000..0a21fa4d68 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel-trigger.tsx @@ -0,0 +1,30 @@ +import { mergeProps } from '@zag-js/react' +import { forwardRef } from 'react' +import { type HTMLProps, type PolymorphicProps, ark } from '../factory' +import { usePresenceContext } from '../presence' +import { useFloatingPanelContext } from './use-floating-panel-context' + +export interface FloatingPanelTriggerBaseProps extends PolymorphicProps {} +export interface FloatingPanelTriggerProps + extends HTMLProps<'button'>, + FloatingPanelTriggerBaseProps {} + +export const FloatingPanelTrigger = forwardRef( + (props, ref) => { + const floatingPanel = useFloatingPanelContext() + const presence = usePresenceContext() + const mergedProps = mergeProps( + { + ...floatingPanel.getTriggerProps(), + 'aria-controls': presence.unmounted + ? undefined + : floatingPanel.getTriggerProps()['aria-controls'], + }, + props, + ) + + return + }, +) + +FloatingPanelTrigger.displayName = 'FloatingPanelTrigger' diff --git a/packages/react/src/components/floating-panel/floating-panel.anatomy.ts b/packages/react/src/components/floating-panel/floating-panel.anatomy.ts new file mode 100644 index 0000000000..3a94f987a5 --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel.anatomy.ts @@ -0,0 +1 @@ +export { anatomy as floatingPanelAnatomy } from '@zag-js/floating-panel' diff --git a/packages/react/src/components/floating-panel/floating-panel.stories.tsx b/packages/react/src/components/floating-panel/floating-panel.stories.tsx new file mode 100644 index 0000000000..516589c3ad --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta } from '@storybook/react' + +const meta: Meta = { + title: 'Components / Floating Panel', +} + +export default meta + +export { Basic } from './examples/basic' +export { Controlled } from './examples/controlled' +export { LazyMount } from './examples/lazy-mount' +export { RenderFn } from './examples/render-fn' diff --git a/packages/react/src/components/floating-panel/floating-panel.ts b/packages/react/src/components/floating-panel/floating-panel.ts new file mode 100644 index 0000000000..a7a9ba2b4e --- /dev/null +++ b/packages/react/src/components/floating-panel/floating-panel.ts @@ -0,0 +1,76 @@ +export type { OpenChangeDetails } from '@zag-js/floating-panel' +export { + FloatingPanelBody as Body, + type FloatingPanelBodyProps as BodyProps, + type FloatingPanelBodyBaseProps as BodyBaseProps, +} from './floating-panel-body' +export { + FloatingPanelCloseTrigger as CloseTrigger, + type FloatingPanelCloseTriggerProps as CloseTriggerProps, + type FloatingPanelCloseTriggerBaseProps as CloseTriggerBaseProps, +} from './floating-panel-close-trigger' +export { + FloatingPanelContent as Content, + type FloatingPanelContentProps as ContentProps, + type FloatingPanelContentBaseProps as ContentBaseProps, +} from './floating-panel-content' +export { + FloatingPanelDock as Dock, + type FloatingPanelDockProps as DockProps, + type FloatingPanelDockBaseProps as DockBaseProps, +} from './floating-panel-dock' +export { + FloatingPanelDragTrigger as DragTrigger, + type FloatingPanelDragTriggerProps as DragTriggerProps, + type FloatingPanelDragTriggerBaseProps as DragTriggerBaseProps, +} from './floating-panel-drag-trigger' +export { + FloatingPanelHeader as Header, + type FloatingPanelHeaderProps as HeaderProps, + type FloatingPanelHeaderBaseProps as HeaderBaseProps, +} from './floating-panel-header' +export { + FloatingPanelMaximizeTrigger as MaximizeTrigger, + type FloatingPanelMaximizeTriggerProps as MaximizeTriggerProps, + type FloatingPanelMaximizeTriggerBaseProps as MaximizeTriggerBaseProps, +} from './floating-panel-maximize-trigger' +export { + FloatingPanelMinimizeTrigger as MinimizeTrigger, + type FloatingPanelMinimizeTriggerProps as MinimizeTriggerProps, + type FloatingPanelMinimizeTriggerBaseProps as MinimizeTriggerBaseProps, +} from './floating-panel-minimize-trigger' +export { + FloatingPanelPositioner as Positioner, + type FloatingPanelPositionerProps as PositionerProps, + type FloatingPanelPositionerBaseProps as PositionerBaseProps, +} from './floating-panel-positioner' +export { + FloatingPanelResizeTrigger as ResizeTrigger, + type FloatingPanelResizeTriggerProps as ResizeTriggerProps, + type FloatingPanelResizeTriggerBaseProps as ResizeTriggerBaseProps, +} from './floating-panel-resize-trigger' +export { + FloatingPanelRestoreTrigger as RestoreTrigger, + type FloatingPanelRestoreTriggerProps as RestoreTriggerProps, + type FloatingPanelRestoreTriggerBaseProps as RestoreTriggerBaseProps, +} from './floating-panel-restore-trigger' +export { + FloatingPanelRoot as Root, + type FloatingPanelRootProps as RootProps, + type FloatingPanelRootBaseProps as RootBaseProps, +} from './floating-panel-root' +export { + FloatingPanelRootProvider as RootProvider, + type FloatingPanelRootProviderProps as RootProviderProps, + type FloatingPanelRootProviderBaseProps as RootProviderBaseProps, +} from './floating-panel-root-provider' +export { + FloatingPanelTitle as Title, + type FloatingPanelTitleProps as TitleProps, + type FloatingPanelTitleBaseProps as TitleBaseProps, +} from './floating-panel-title' +export { + FloatingPanelTrigger as Trigger, + type FloatingPanelTriggerProps as TriggerProps, + type FloatingPanelTriggerBaseProps as TriggerBaseProps, +} from './floating-panel-trigger' diff --git a/packages/react/src/components/floating-panel/index.ts b/packages/react/src/components/floating-panel/index.ts new file mode 100644 index 0000000000..ce57d6a8b9 --- /dev/null +++ b/packages/react/src/components/floating-panel/index.ts @@ -0,0 +1,89 @@ +export type { OpenChangeDetails as FloatingPanelOpenChangeDetails } from '@zag-js/floating-panel' +export { + FloatingPanelBody, + type FloatingPanelBodyProps, + type FloatingPanelBodyBaseProps, +} from './floating-panel-body' +export { + FloatingPanelCloseTrigger, + type FloatingPanelCloseTriggerProps, + type FloatingPanelCloseTriggerBaseProps, +} from './floating-panel-close-trigger' +export { + FloatingPanelContent, + type FloatingPanelContentProps, + type FloatingPanelContentBaseProps, +} from './floating-panel-content' +export { + FloatingPanelDock, + type FloatingPanelDockProps, + type FloatingPanelDockBaseProps, +} from './floating-panel-dock' +export { + FloatingPanelContext, + type FloatingPanelContextProps, +} from './floating-panel-context' +export { + FloatingPanelDragTrigger, + type FloatingPanelDragTriggerProps, + type FloatingPanelDragTriggerBaseProps, +} from './floating-panel-drag-trigger' +export { + FloatingPanelHeader, + type FloatingPanelHeaderProps, + type FloatingPanelHeaderBaseProps, +} from './floating-panel-header' +export { + FloatingPanelMaximizeTrigger, + type FloatingPanelMaximizeTriggerProps, + type FloatingPanelMaximizeTriggerBaseProps, +} from './floating-panel-maximize-trigger' +export { + FloatingPanelMinimizeTrigger, + type FloatingPanelMinimizeTriggerProps, + type FloatingPanelMinimizeTriggerBaseProps, +} from './floating-panel-minimize-trigger' +export { + FloatingPanelPositioner, + type FloatingPanelPositionerProps, + type FloatingPanelPositionerBaseProps, +} from './floating-panel-positioner' +export { + FloatingPanelResizeTrigger, + type FloatingPanelResizeTriggerProps, + type FloatingPanelResizeTriggerBaseProps, +} from './floating-panel-resize-trigger' +export { + FloatingPanelRestoreTrigger, + type FloatingPanelRestoreTriggerProps, + type FloatingPanelRestoreTriggerBaseProps, +} from './floating-panel-restore-trigger' +export { + FloatingPanelRoot, + type FloatingPanelRootProps, + type FloatingPanelRootBaseProps, +} from './floating-panel-root' +export { + FloatingPanelRootProvider, + type FloatingPanelRootProviderProps, + type FloatingPanelRootProviderBaseProps, +} from './floating-panel-root-provider' +export { + FloatingPanelTitle, + type FloatingPanelTitleProps, + type FloatingPanelTitleBaseProps, +} from './floating-panel-title' +export { + FloatingPanelTrigger, + type FloatingPanelTriggerProps, + type FloatingPanelTriggerBaseProps, +} from './floating-panel-trigger' +export { + useFloatingPanel, + type UseFloatingPanelProps, + type UseFloatingPanelReturn, +} from './use-floating-panel' +export { useFloatingPanelContext, type UseFloatingPanelContext } from './use-floating-panel-context' +export { floatingPanelAnatomy } from './floating-panel.anatomy' + +export * as FloatingPanel from './floating-panel' diff --git a/packages/react/src/components/floating-panel/tests/basic.tsx b/packages/react/src/components/floating-panel/tests/basic.tsx new file mode 100644 index 0000000000..d6efc209e4 --- /dev/null +++ b/packages/react/src/components/floating-panel/tests/basic.tsx @@ -0,0 +1,32 @@ +import { FloatingPanel, type FloatingPanelRootProps } from '../..' + +export const ComponentUnderTest = (props: FloatingPanelRootProps) => ( + + Open FloatingPanel + + + + + + FloatingPanel Title + minimize window + maximize window + restore window + close window + + + +

Some content

+
+ + + + + + + + +
+
+
+) diff --git a/packages/react/src/components/floating-panel/tests/floating-panel.spec.tsx b/packages/react/src/components/floating-panel/tests/floating-panel.spec.tsx new file mode 100644 index 0000000000..47221887c2 --- /dev/null +++ b/packages/react/src/components/floating-panel/tests/floating-panel.spec.tsx @@ -0,0 +1,105 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react/pure' +import user from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { FloatingPanel, floatingPanelAnatomy } from '../' +import { getExports, getParts } from '../../../setup-test' +import { ComponentUnderTest } from './basic' + +describe('FloatingPanel / Parts & Exports', () => { + afterAll(() => { + cleanup() + }) + + render() + + it.each(getParts(floatingPanelAnatomy))('should render part! %s', async (part) => { + expect(document.querySelector(part)).toBeInTheDocument() + }) + + it.each(getExports(floatingPanelAnatomy))('should export %s', async (part) => { + expect(FloatingPanel[part]).toBeDefined() + }) +}) + +describe('FloatingPanel', () => { + afterEach(() => { + cleanup() + }) + + it('should have no a11y violations', async () => { + const { container } = render() + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should show floatingPanel content when opened', async () => { + render() + + await user.click(screen.getByText('Open FloatingPanel')) + expect(await screen.findByText('FloatingPanel Title')).toBeVisible() + + await user.click(screen.getByRole('button', { name: /close window/i })) + await waitFor(() => expect(screen.queryByText('FloatingPanel Title')).not.toBeVisible()) + }) + + it('should invoke onOpenChange if floatingPanel is closed', async () => { + const onOpenChange = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: /close window/i })) + expect(onOpenChange).toHaveBeenCalledTimes(1) + }) + + it('should be able to lazy mount', async () => { + render() + + expect(screen.queryByTestId('positioner')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Open FloatingPanel' })) + expect(screen.getByTestId('positioner')).toBeInTheDocument() + + screen.logTestingPlaygroundURL() + + await user.click(screen.getByRole('button', { name: /close window/i })) + expect(screen.getByTestId('positioner')).toBeInTheDocument() + }) + + it('should not have aria-controls if lazy mounted', async () => { + render() + + expect(screen.getByRole('button', { name: 'Open FloatingPanel' })).not.toHaveAttribute( + 'aria-controls', + ) + }) + + it('should lazy mount and unmount on exit', async () => { + render() + + expect(screen.queryByTestId('positioner')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Open FloatingPanel' })) + expect(screen.getByTestId('positioner')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close window/i })) + await waitFor(() => expect(screen.queryByTestId('positioner')).not.toBeInTheDocument()) + }) + + it('should be fully controlled (true)', async () => { + render() + + expect(screen.queryByRole('button', { name: /close window/i })).toBeVisible() + + await user.click(screen.getByRole('button', { name: /close window/i })) + expect(screen.queryByRole('button', { name: /close window/i })).toBeVisible() + }) + + it('should be fully controlled (false)', async () => { + render() + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Open FloatingPanel' })) + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + }) +}) diff --git a/packages/react/src/components/floating-panel/use-floating-panel-context.ts b/packages/react/src/components/floating-panel/use-floating-panel-context.ts new file mode 100644 index 0000000000..b9ca55c0f6 --- /dev/null +++ b/packages/react/src/components/floating-panel/use-floating-panel-context.ts @@ -0,0 +1,11 @@ +import { createContext } from '../../utils/create-context' +import type { UseFloatingPanelReturn } from './use-floating-panel' + +export interface UseFloatingPanelContext extends UseFloatingPanelReturn {} + +export const [FloatingPanelProvider, useFloatingPanelContext] = + createContext({ + name: 'FloatingPanelContext', + hookName: 'useFloatingPanelContext', + providerName: '', + }) diff --git a/packages/react/src/components/floating-panel/use-floating-panel.ts b/packages/react/src/components/floating-panel/use-floating-panel.ts new file mode 100644 index 0000000000..ad636d2c85 --- /dev/null +++ b/packages/react/src/components/floating-panel/use-floating-panel.ts @@ -0,0 +1,40 @@ +import * as floatingPanel from '@zag-js/floating-panel' +import { type PropTypes, normalizeProps, useMachine } from '@zag-js/react' +import { useId } from 'react' +import { useEnvironmentContext, useLocaleContext } from '../../providers' +import type { Optional } from '../../types' +import { useEvent } from '../../utils/use-event' + +export interface UseFloatingPanelProps + extends Optional, 'id'> { + /** + * The initial open state of the floating panel when it is first rendered. + * Use when you do not need to control its open state. + */ + defaultOpen?: floatingPanel.Context['open'] +} + +export interface UseFloatingPanelReturn extends floatingPanel.Api {} + +export const useFloatingPanel = (props: UseFloatingPanelProps = {}): UseFloatingPanelReturn => { + const { getRootNode } = useEnvironmentContext() + const { dir } = useLocaleContext() + + const initialContext: floatingPanel.Context = { + id: useId(), + dir, + getRootNode, + open: props.defaultOpen, + ...props, + } + + const context: floatingPanel.Context = { + ...initialContext, + open: props.open, + onOpenChange: useEvent(props.onOpenChange, { sync: true }), + } + + const [state, send] = useMachine(floatingPanel.machine(initialContext), { context }) + + return floatingPanel.connect(state, send, normalizeProps) +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 7e2e6dc9b1..dc0541b4dd 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -13,6 +13,7 @@ export * from './factory' export * from './field' export * from './fieldset' export * from './file-upload' +export * from './floating-panel' export * from './format' export * from './highlight' export * from './hover-card'