Canvas Shell
Coque de disposition pour les espaces de travail canevas avec barre superieure, rail gauche, dock droit et regions d'emplacement inferieures. Installez Canvas Shell depuis le registre VLLNT UI avec la CLI shadcn.
Coque de disposition pour les espaces de travail canevas avec barre superieure, rail gauche, dock droit et regions d'emplacement inferieures. Fait partie de la famille utility dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Canvas Shell a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/canvas-shell.jsonSource
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.Ref<HTMLElement> | undefined,
) {
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 CanvasShellContent({
content,
contentStyle,
}: {
content: 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">
{content}
</div>
</div>
);
}
function renderFloatingCanvasShell(
{
bottomBar,
bottomSlot,
children,
chromeInset = 16,
className,
contentPadding,
leftBar,
leftRail,
rightBar,
rightDock,
style,
topBar,
...props
}: CanvasShellProps,
ref: React.Ref<HTMLElement> | undefined,
) {
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,oklch(var(--background)/0.94),oklch(var(--muted)/0.6))]",
className,
)}
ref={ref}
style={mergedStyle}
{...props}
>
<div className="absolute inset-0 bg-[linear-gradient(180deg,oklch(var(--background)/0.94),oklch(var(--background)/0.8))]" />
<CanvasShellChromeBefore
inset={inset}
leftBar={hasLeftBar ? resolvedLeftBar : undefined}
topBar={hasTopBar ? topBar : undefined}
/>
<CanvasShellContent content={children} contentStyle={contentStyle} />
<CanvasShellChromeAfter
bottomBar={hasBottomBar ? resolvedBottomBar : undefined}
inset={inset}
rightBar={hasRightBar ? resolvedRightBar : undefined}
/>
</section>
);
}
const CanvasShell = ({
ref,
...props
}: CanvasShellProps & { ref?: React.Ref<HTMLElement> }) => {
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 };
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.