Canvas Shell

Layout shell for canvas workspaces with top bar, left rail, right dock, and bottom slot regions.

Signaler un bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/canvas-shell.json
bash

Storybook

Explorez les variantes, controles et verifications d'accessibilite dans le playground Storybook interactif.

Voir dans Storybook

Code

import { forwardRef } from "react";

import type { CSSProperties, ReactNode } from "react";

import { cn } from "../../lib/utils";

import type { CanvasShellInsets } from "./canvas-shell-route-config";

export type CanvasShellProps = React.ComponentPropsWithoutRef<"section"> & {
  bottomBar?: ReactNode;
  bottomSlot?: ReactNode;
  children?: ReactNode;
  chromeInset?: number | string;
  contentPadding?: CanvasShellInsets;
  leftBar?: ReactNode;
  leftRail?: ReactNode;
  rightBar?: ReactNode;
  rightDock?: ReactNode;
  topBar?: ReactNode;
};

type CanvasShellChromeProps = {
  bottomBar?: ReactNode;
  inset: string;
  leftBar?: ReactNode;
  rightBar?: ReactNode;
  topBar?: ReactNode;
};

type CanvasShellSafeAreaStyle = CSSProperties & {
  "--canvas-shell-safe-bottom": string;
  "--canvas-shell-safe-left": string;
  "--canvas-shell-safe-right": string;
  "--canvas-shell-safe-top": string;
};

function toInsetValue(value: number | string | undefined) {
  if (typeof value === "number") {
    return `${value}px`;
  }

  return value;
}

const FLOATING_BOTTOM_BAR_FOOTPRINT = "3.5rem";
const FLOATING_LEFT_BAR_FOOTPRINT = "4.5rem";
const FLOATING_RIGHT_BAR_FOOTPRINT = "18rem";
const FLOATING_TOP_BAR_FOOTPRINT = "3.5rem";

function getReservedInset(
  inset: string,
  footprint: string,
  override: number | string | undefined,
) {
  return toInsetValue(override) ?? `calc(${inset} + ${footprint})`;
}

function getSafeAreaInsets({
  chromeInset,
  contentPadding,
  hasBottomBar,
  hasLeftBar,
  hasRightBar,
  hasTopBar,
}: {
  chromeInset: number | string;
  contentPadding?: CanvasShellInsets;
  hasBottomBar: boolean;
  hasLeftBar: boolean;
  hasRightBar: boolean;
  hasTopBar: boolean;
}) {
  const inset = toInsetValue(chromeInset) ?? "16px";

  return {
    bottom: hasBottomBar
      ? getReservedInset(
          inset,
          FLOATING_BOTTOM_BAR_FOOTPRINT,
          contentPadding?.bottom,
        )
      : (toInsetValue(contentPadding?.bottom) ?? inset),
    left: hasLeftBar
      ? getReservedInset(
          inset,
          FLOATING_LEFT_BAR_FOOTPRINT,
          contentPadding?.left,
        )
      : (toInsetValue(contentPadding?.left) ?? inset),
    right: hasRightBar
      ? getReservedInset(
          inset,
          FLOATING_RIGHT_BAR_FOOTPRINT,
          contentPadding?.right,
        )
      : (toInsetValue(contentPadding?.right) ?? inset),
    top: hasTopBar
      ? getReservedInset(inset, FLOATING_TOP_BAR_FOOTPRINT, contentPadding?.top)
      : (toInsetValue(contentPadding?.top) ?? inset),
  };
}

function getSafeAreaStyle(
  insets: ReturnType<typeof getSafeAreaInsets>,
): CanvasShellSafeAreaStyle {
  return {
    "--canvas-shell-safe-bottom": insets.bottom,
    "--canvas-shell-safe-left": insets.left,
    "--canvas-shell-safe-right": insets.right,
    "--canvas-shell-safe-top": insets.top,
  } satisfies CanvasShellSafeAreaStyle;
}

const hasChromeContent = Boolean;

type CanvasShellChromeAfterProps = Pick<
  CanvasShellChromeProps,
  "bottomBar" | "inset" | "rightBar"
>;

function CanvasShellChromeBefore({
  inset,
  leftBar,
  topBar,
}: Pick<CanvasShellChromeProps, "inset" | "leftBar" | "topBar">) {
  return (
    <>
      {hasChromeContent(topBar) ? (
        <div
          className="pointer-events-none absolute inset-x-0 z-30"
          style={{ top: inset }}
        >
          <div
            className="pointer-events-auto mx-auto w-full max-w-[960px]"
            style={{ paddingLeft: inset, paddingRight: inset }}
          >
            {topBar}
          </div>
        </div>
      ) : null}
      {hasChromeContent(leftBar) ? (
        <div
          className="pointer-events-none absolute left-0 z-30 flex"
          style={{
            bottom: "var(--canvas-shell-safe-bottom)",
            left: inset,
            top: "var(--canvas-shell-safe-top)",
          }}
        >
          <div className="pointer-events-auto flex">{leftBar}</div>
        </div>
      ) : null}
    </>
  );
}

function CanvasShellChromeAfter({
  bottomBar,
  inset,
  rightBar,
}: CanvasShellChromeAfterProps) {
  return (
    <>
      {hasChromeContent(rightBar) ? (
        <div
          className="pointer-events-none absolute right-0 z-30 flex"
          style={{
            bottom: "var(--canvas-shell-safe-bottom)",
            right: inset,
            top: "var(--canvas-shell-safe-top)",
          }}
        >
          <div className="pointer-events-auto flex">{rightBar}</div>
        </div>
      ) : null}
      {hasChromeContent(bottomBar) ? (
        <div
          className="pointer-events-none absolute inset-x-0 z-30"
          style={{ bottom: inset }}
        >
          <div
            className="pointer-events-auto mx-auto w-full max-w-[960px]"
            style={{ paddingLeft: inset, paddingRight: inset }}
          >
            {bottomBar}
          </div>
        </div>
      ) : null}
    </>
  );
}

function renderLegacyCanvasShell(
  {
    bottomBar: _bottomBar,
    bottomSlot,
    children,
    chromeInset: _chromeInset = 16,
    className,
    contentPadding: _contentPadding,
    leftBar: _leftBar,
    leftRail,
    rightBar: _rightBar,
    rightDock,
    style,
    topBar,
    ...props
  }: CanvasShellProps,
  ref: React.ForwardedRef<HTMLElement>,
) {
  return (
    <section
      className={cn(
        "flex min-h-[720px] w-full flex-col overflow-hidden rounded-md border border-border bg-background",
        className,
      )}
      ref={ref}
      style={style}
      {...props}
    >
      {topBar}
      <div className="grid min-h-0 flex-1 grid-cols-[auto_minmax(0,1fr)_auto] overflow-hidden bg-background">
        {leftRail ?? <div />}
        <div className="relative min-h-0 min-w-0 overflow-hidden">
          {children}
        </div>
        {rightDock ?? <div />}
      </div>
      {bottomSlot ? (
        <div className="border-t border-border bg-background px-4 py-2">
          {bottomSlot}
        </div>
      ) : null}
    </section>
  );
}

function renderFloatingContent(
  children: ReactNode,
  contentStyle: CSSProperties,
) {
  return (
    <div
      className="relative z-0 h-full w-full min-h-0 min-w-0"
      data-slot="canvas-shell-content"
      style={contentStyle}
    >
      <div className="h-full w-full min-h-0 min-w-0 overflow-hidden">
        {children}
      </div>
    </div>
  );
}

function renderFloatingCanvasShell(
  {
    bottomBar,
    bottomSlot,
    children,
    chromeInset = 16,
    className,
    contentPadding,
    leftBar,
    leftRail,
    rightBar,
    rightDock,
    style,
    topBar,
    ...props
  }: CanvasShellProps,
  ref: React.ForwardedRef<HTMLElement>,
) {
  const inset = toInsetValue(chromeInset) ?? "16px";
  const resolvedBottomBar = bottomBar ?? bottomSlot;
  const resolvedLeftBar = leftBar ?? leftRail;
  const resolvedRightBar = rightBar ?? rightDock;

  const hasTopBar = hasChromeContent(topBar);
  const hasLeftBar = hasChromeContent(resolvedLeftBar);
  const hasRightBar = hasChromeContent(resolvedRightBar);
  const hasBottomBar = hasChromeContent(resolvedBottomBar);
  const safeAreaInsets = getSafeAreaInsets({
    chromeInset,
    contentPadding,
    hasBottomBar,
    hasLeftBar,
    hasRightBar,
    hasTopBar,
  });
  const mergedStyle = {
    ...getSafeAreaStyle(safeAreaInsets),
    ...style,
  } satisfies CSSProperties;
  const contentStyle = {
    paddingBottom: "var(--canvas-shell-safe-bottom)",
    paddingLeft: "var(--canvas-shell-safe-left)",
    paddingRight: "var(--canvas-shell-safe-right)",
    paddingTop: "var(--canvas-shell-safe-top)",
  } satisfies CSSProperties;

  return (
    <section
      className={cn(
        "relative isolate flex min-h-[720px] w-full overflow-hidden bg-[radial-gradient(circle_at_top,hsl(var(--background)/0.94),hsl(var(--muted)/0.6))]",
        className,
      )}
      ref={ref}
      style={mergedStyle}
      {...props}
    >
      <div className="absolute inset-0 bg-[linear-gradient(180deg,hsl(var(--background)/0.94),hsl(var(--background)/0.8))]" />
      <CanvasShellChromeBefore
        inset={inset}
        leftBar={hasLeftBar ? resolvedLeftBar : undefined}
        topBar={hasTopBar ? topBar : undefined}
      />
      {renderFloatingContent(children, contentStyle)}
      <CanvasShellChromeAfter
        bottomBar={hasBottomBar ? resolvedBottomBar : undefined}
        inset={inset}
        rightBar={hasRightBar ? resolvedRightBar : undefined}
      />
    </section>
  );
}

const CanvasShell = forwardRef<HTMLElement, CanvasShellProps>((props, ref) => {
  const { bottomBar, chromeInset, contentPadding, leftBar, rightBar } = props;
  const hasExplicitChromeInset = Object.prototype.hasOwnProperty.call(
    props,
    "chromeInset",
  );
  const usesFloatingChrome =
    hasChromeContent(bottomBar) ||
    hasChromeContent(leftBar) ||
    hasChromeContent(rightBar) ||
    contentPadding !== undefined ||
    (hasExplicitChromeInset && chromeInset !== undefined);

  if (!usesFloatingChrome) {
    return renderLegacyCanvasShell(props, ref);
  }

  return renderFloatingCanvasShell(props, ref);
});

CanvasShell.displayName = "CanvasShell";

export { CanvasShell };
typescript

Dependances

  • @vllnt/ui@^0.2.1