Floating Sheet

Categorystyled
ArchitectureReact Client Component
Dependencies

Usage

import { useState } from "react";

import { Button } from "@/components/react/styled/button";
import { FloatingSheet } from "@/components/react/styled/floatingSheet";
import { DemoWrapper } from "@/demos/react/demoWrapper";

export const FloatingSheetDemo = ({ code }: { code: string }) => {
  const [state, setState] = useState(false);

  return (
    <DemoWrapper code={code}>
      <Button onClick={setState.bind(null, true)}>Open Floating</Button>
      <FloatingSheet
        title="Floating Sheet"
        subtitle="FloatingSheet Subtitle"
        actions={{
          accept: {
            event: () => console.log("Accepted"),
            label: "Accept",
          },
          dismiss: {
            event: () => console.log("Dismissed"),
            label: "Dismiss",
          },
        }}
        state={state}
        setState={setState}
      >
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias culpa
        laudantium dolores rerum! Hic pariatur velit fugiat error, vitae
        similique cupiditate ab, minima nesciunt minus exercitationem eaque,
        accusamus laudantium magni?
      </FloatingSheet>
    </DemoWrapper>
  );
};

Install Instructions

Verifying dependencies

Make sure the following dependencies are satisfied:

  • dialog
  • useTouchDialogDrag

Copy and paste the code into that file

import { lazy, Suspense } from "react";

// Sub-Component
import { Button } from "./button";
const Dialog = lazy(() =>
  import("@/components/react/primitive/dialog").then((module) => ({
    default: module.Dialog,
  }))
);

// Types
import type { DialogProps } from "@/components/react/primitive/dialog";

// Icon
import { XMarkIcon } from "@heroicons/react/24/solid";

// CVA
import { cva } from "class-variance-authority";

// Hooks
import { useTouchDialogDrag } from "@/hooks/react/useTouchDialogDrag";

const floatingSheetClassNames = cva([
  "flex-col",
  "rounded-3xl",
  "border",
  "p-6",
  "border-neutral-200",
  "bg-neutral-100",
  "dark:border-neutral-800",
  "dark:bg-neutral-900",
  "md:dark:border-neutral-700",
  "md:border-neutral-300",
  "opacity-100",
  "motion-safe:translate-y-[calc(100%_+_theme(space.6))]",
  "motion-reduce:opacity-0",
  "data-[display=true]:opacity-100",
  "bottom-3",
  "top-auto",
  "max-w-none",
  "md:aspect-video",
  "md:w-auto",
  "md:m-0",
  "md:left-[50%]",
  "md:translate-x-[-50%]",
  "md:max-h-[max(300px,_30vh)]",
  "data-[display=true]:translate-y-0",
  "left-[calc(theme(space.3)_+_env(safe-area-inset-left))]",
  "right-[calc(theme(space.3)_+_env(safe-area-inset-right))]",
  "w-[calc(100vw_-_theme(space.6)_-_env(safe-area-inset-left)-_env(safe-area-inset-right))]",
  // ------------ PWA Safari styles ------------ //
  "standalone:iphone-portrait:rounded-[3rem]",
  "standalone:iphone-portrait:p-8",
  "standalone:iphone-portrait:w-[calc(100vw_-_theme(space.10)_-_env(safe-area-inset-left)-_env(safe-area-inset-right))]",
  "standalone:iphone-portrait:left-[calc(theme(space.5)_+_env(safe-area-inset-left))]",
  "standalone:iphone-portrait:right-[calc(theme(space.5)_+_env(safe-area-inset-right))]",
  "standalone:iphone-portrait:bottom-5",
  "standalone:iphone-portrait:motion-safe:translate-y-[calc(100%_+_theme(space.8)]",
]);

type ActionEvent = {
  event: () => void;
  label: string;
};

export interface FloatingSheetProps
  extends Omit<DialogProps, "className" | "ref"> {
  title: string;
  subtitle?: string;
  actions?: {
    accept?: ActionEvent;
    dismiss?: ActionEvent;
  };
}

/**
 * Floating Sheets provide an elegant way to display information and actions
 *
 * @param {FloatingSheetProps} props
 * @returns {JSX.Element}
 */
export const FloatingSheet = ({
  children,
  title,
  subtitle,
  actions,
  setState,
  ...props
}: FloatingSheetProps): JSX.Element => {
  const { onTouchEnd, onTouchMove, ref } = useTouchDialogDrag({
    onClose: setState?.bind(null, false),
    maxScroll: 300,
  });

  return (
    <Suspense fallback={null}>
      <Dialog
        ref={ref}
        onTouchEnd={onTouchEnd}
        onTouchMove={onTouchMove}
        className={floatingSheetClassNames()}
        transitionDuration={350}
        setState={setState}
        {...props}
      >
        <Button
          square={true}
          onClick={() => setState && setState(false)}
          className="absolute top-6 right-6 rounded-[100%]"
        >
          <XMarkIcon className="w-4 h-4" />
        </Button>
        <header
          data-draggable={true}
          className="flex flex-col justify-center align-middle items-center"
        >
          <p
            data-draggable={true}
            className="font-semibold text-2xl p-0 mt-0 mb-2 only:mb-0 text-center"
          >
            {title}
          </p>
          {subtitle && (
            <p
              data-draggable={true}
              className="p-0 m-0 text-center text-xs font-light opacity-75"
            >
              {subtitle}
            </p>
          )}
        </header>
        {children && <p className="my-4 p-0 text-sm opacity-75">{children}</p>}
        {actions && (
          <div data-draggable={true} className="pt-12 w-[100%] flex">
            {actions.dismiss && (
              <Button
                className="grow"
                variant="ghost"
                onClick={() => {
                  setState && setState(false);
                  actions.dismiss?.event();
                }}
              >
                {actions.dismiss.label}
              </Button>
            )}
            {actions.accept && (
              <Button
                className="grow"
                color="primary"
                onClick={() => {
                  setState && setState(false);
                  actions.accept?.event();
                }}
              >
                {actions.accept.label}
              </Button>
            )}
          </div>
        )}
      </Dialog>
    </Suspense>
  );
};

Loading...