justdlabs / justd

Justd is a chill set of React components, built on top of React Aria Components and Tailwind CSS. Easy to customize and just copy & paste into your React projects.
https://getjustd.com
MIT License
708 stars 36 forks source link

Drawer Fit Content #145

Closed dq-alhq closed 3 months ago

dq-alhq commented 3 months ago

Modify Drawer to fit the content height, backdrop scale and blur effect and close drawer on background tap

  1. In the DrawerOverlayPrimitive component:
    • Added a contentHeight state to track the height of the content.
    • Implemented a ResizeObserver to dynamically update the contentHeight when the content changes.
    • Modified the h calculation to use the contentHeight.
    • Updated the ModalPrimitive style to use the dynamic height based on contentHeight.
  2. Adjusted the dragConstraints to use the dynamic height.
  3. The height style of ModalPrimitive now uses the contentHeight plus drawerMargin, with a fallback to 'auto' when contentHeight is 0.
  4. Added a maxHeight constraint to ensure the drawer doesn't exceed the screen height.
  5. Added a useMotionValueEvent to scale the body down when drawer is open.
  6. Added a motion.section to create backdrop blur effect and function to close the drawer when background tap or clicked
'use client'

import React from 'react'

import { animate, AnimatePresence, type Inertia, motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useTransform } from 'framer-motion'
import { Button, type ButtonProps, Dialog, type DialogProps, Heading, type HeadingProps, Modal, ModalOverlay } from 'react-aria-components'

import { DialogClose, DialogDescription } from './dialog'
import { cn } from './primitive'

const inertiaTransition: Inertia = {
  type: 'inertia',
  bounceStiffness: 300,
  bounceDamping: 40,
  timeConstant: 300
}
const staticTransition = {
  duration: 0.5,
  ease: [0.32, 0.72, 0, 1]
}
const drawerMargin = 40
const drawerRadius = 32

interface DrawerContextType {
  isOpen: boolean
  openDrawer: () => void
  closeDrawer: () => void
  withNotch?: boolean
}

const DrawerContext = React.createContext<DrawerContextType | undefined>(undefined)

const useDrawerContext = () => {
  const context = React.useContext(DrawerContext)
  if (context === undefined) {
    throw new Error('useDrawerContext must be used within a Drawer')
  }
  return context
}

const ModalPrimitive = motion(Modal)
const ModalOverlayPrimitive = motion(ModalOverlay)

const DrawerOverlayPrimitive = (props: React.ComponentProps<typeof ModalOverlayPrimitive>) => {
  const { closeDrawer, withNotch } = useDrawerContext()
  const [contentHeight, setContentHeight] = React.useState(0)

  const h = Math.min(contentHeight + drawerMargin, window.innerHeight - drawerMargin)
  const y = useMotionValue(h)
  const bgOpacity = useTransform(y, [0, h], [0.5, 0])
  const bg = useMotionTemplate`rgba(0, 0, 0, ${bgOpacity})`

  const root = document.getElementsByTagName('main')[0] as HTMLElement
  const bodyScale = useTransform(
    y,
    [0, h],
    [(window.innerWidth - drawerMargin) / window.innerWidth, 1]
  )
  const bodyTranslate = useTransform(y, [0, h], [drawerMargin - drawerRadius, 0])
  const bodyBorderRadius = useTransform(y, [0, h], [drawerRadius, 0])

  useMotionValueEvent(bodyScale, 'change', (v: any) => (root.style.scale = `${v}`))
  useMotionValueEvent(bodyTranslate, 'change', (v: any) => (root.style.translate = `0 ${v}px`))
  useMotionValueEvent(bodyBorderRadius, 'change', (v) => (root.style.borderRadius = `${v}px`))

  return (
    <>
      <ModalOverlayPrimitive
        isOpen
        onOpenChange={closeDrawer}
        className="fixed inset-0 z-50"
        style={{ backgroundColor: bg as any }}
      >
        <motion.section
          aria-hidden
          onTap={closeDrawer}
          className="fixed inset-0 backdrop-blur-sm"
          initial="collapsed"
          animate="open"
          exit="collapsed"
          variants={{
            open: { opacity: 1 },
            collapsed: { opacity: 0 }
          }}
          transition={{ duration: 0.4, ease: [0.04, 0.62, 0.23, 0.98] }}
        />
        <ModalPrimitive
          className={cn(
            'absolute bottom-0 w-full rounded-t-2xl bg-tertiary shadow-lg ring-1 ring-fg/10',
            props.className
          )}
          initial={{ y: h }}
          animate={{ y: 0 }}
          exit={{ y: h }}
          transition={staticTransition}
          style={{
            y,
            top: 'auto',
            height: contentHeight > 0 ? `${contentHeight + drawerMargin}px` : 'auto',
            maxHeight: `calc(100% - ${drawerMargin}px)`
          }}
          drag="y"
          dragConstraints={{ top: 0, bottom: h }}
          onDragEnd={(_e, { offset, velocity }) => {
            if (offset.y > h * 0.5 || velocity.y > 10) {
              closeDrawer()
            } else {
              animate(y, 0, { ...inertiaTransition, min: 0, max: 0 })
            }
          }}
          {...props}
        >
          <>
            {withNotch && <div className="notch mx-auto mt-2 h-1.5 w-10 rounded-full bg-fg/20" />}
            <div
              ref={(el) => {
                if (el) {
                  const resizeObserver = new ResizeObserver((entries) => {
                    for (const entry of entries) {
                      setContentHeight(entry.contentRect.height)
                    }
                  })
                  resizeObserver.observe(el)
                  return () => resizeObserver.disconnect()
                }
              }}
            >
              {props.children as React.ReactNode}
            </div>
          </>
        </ModalPrimitive>
      </ModalOverlayPrimitive>
    </>
  )
}

interface DrawerContentPrimitiveProps extends Omit<React.ComponentProps<typeof Modal>, 'children'> {
  children?: DialogProps['children']
}

const DrawerContentPrimitive = (props: DrawerContentPrimitiveProps) => {
  const { isOpen } = useDrawerContext()

  const h = window.innerHeight - drawerMargin
  const y = useMotionValue(h)

  const bodyScale = useTransform(
    y,
    [0, h],
    [(window.innerWidth - drawerMargin) / window.innerWidth, 1]
  )
  const bodyTranslate = useTransform(y, [0, h], [drawerMargin - drawerRadius, 0])
  const bodyBorderRadius = useTransform(y, [0, h], [drawerRadius, 0])
  return (
    <motion.div
      style={{
        scale: bodyScale,
        borderRadius: bodyBorderRadius,
        y: bodyTranslate,
        transformOrigin: 'center 0'
      }}
    >
      <AnimatePresence>{isOpen && <>{props.children}</>}</AnimatePresence>
    </motion.div>
  )
}

const DrawerTrigger = (props: ButtonProps) => {
  const { openDrawer } = useDrawerContext()

  return <Button onPress={openDrawer} {...props} />
}

interface DrawerProps {
  children: React.ReactNode
  isOpen?: boolean
  withNotch?: boolean
  onOpenChange?: (isOpen: boolean) => void
}

const Drawer = ({
  children,
  withNotch = true,
  isOpen: controlledIsOpen,
  onOpenChange
}: DrawerProps) => {
  const [internalIsOpen, setInternalIsOpen] = React.useState(false)

  const isControlled = controlledIsOpen !== undefined
  const isOpen = isControlled ? controlledIsOpen : internalIsOpen

  React.useEffect(() => {
    if (isControlled && onOpenChange) {
      onOpenChange(isOpen)
    }
  }, [isOpen, isControlled, onOpenChange])

  const openDrawer = () => {
    if (isControlled && onOpenChange) {
      onOpenChange(true)
    } else {
      setInternalIsOpen(true)
    }
  }

  const closeDrawer = () => {
    if (isControlled && onOpenChange) {
      onOpenChange(false)
    } else {
      setInternalIsOpen(false)
    }
  }

  if (typeof window === 'undefined') {
    return null
  }

  return (
    <DrawerContext.Provider value={{ isOpen, openDrawer, closeDrawer, withNotch }}>
      {children}
    </DrawerContext.Provider>
  )
}

const DrawerContent = ({
  children,
  className,
  ...props
}: React.ComponentProps<typeof DrawerContentPrimitive>) => {
  return (
    <DrawerContentPrimitive>
      <DrawerOverlayPrimitive {...props}>
        <Dialog className="mx-auto flex max-w-3xl flex-col justify-between overflow-y-auto px-4 pt-4 outline-none">
          {(values) => <>{typeof children === 'function' ? children(values) : children}</>}
        </Dialog>
      </DrawerOverlayPrimitive>
    </DrawerContentPrimitive>
  )
}

const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('flex flex-col gap-y-1 text-center sm:text-left', className)} {...props} />
)

const DrawerTitle = ({ className, ...props }: HeadingProps) => (
  <Heading
    slot="title"
    className={cn('text-lg font-semibold leading-none tracking-tight', className)}
    {...props}
  />
)

const DrawerDescription = DialogDescription

const DrawerBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div className={cn('flex-1 overflow-y-auto overflow-x-hidden py-4', className)} {...props} />
)

const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      'flex shrink-0 pb-1 flex-col-reverse gap-2 sm:flex-row sm:justify-between [&_button:first-child:nth-last-child(1)]:w-full',
      className
    )}
    {...props}
  />
)

const DrawerClose = (props: React.ComponentProps<typeof DialogClose>) => {
  return <DialogClose shape="circle" {...props} />
}

export {
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger
}
irsyadadl commented 3 months ago

@dq-alhq Can you make a pr ?

irsyadadl commented 3 months ago

It'll closed as the pr #146 146