redesign skills library (#8868)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-04-27 23:58:17 -07:00
committed by GitHub
parent 96fa25bce8
commit b35eaf4bf9
33 changed files with 2879 additions and 767 deletions
@@ -15,18 +15,54 @@
"saving": "Saving..."
},
"view": {
"backToSkills": "Back to skills",
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
"deleteTitle": "Delete skill?",
"description": "Reusable instructions for your AI agents",
"category": "Category",
"categories": {
"clear": "Clear categories",
"count": "{{count}} categories",
"filter": "Filter by category",
"label": "Categories",
"options": {
"design": "Design",
"engineering": "Engineering",
"general": "General",
"integrations": "Integrations",
"operations": "Operations",
"productivity": "Productivity",
"quality": "Quality",
"research": "Research",
"writing": "Writing"
}
},
"description": "Skills are reusable instructions that help an agent handle specific tasks, workflows, or tools.",
"detailEmptyDescription": "Select a skill to inspect its source, location, and instructions.",
"detailEmptyTitle": "Choose a skill",
"dropFile": "or drop a file",
"emptyDescription": "Create a skill or drop a .skill.json file here.",
"emptyDescription": "Create a skill or import one to get started.",
"emptyTitle": "No skills yet",
"exportedTo": "Exported to {{filename}}",
"filePath": "File path",
"filtersAllSources": "All",
"filtersGlobal": "Personal",
"instructions": "Instructions",
"location": "Location",
"more": "More",
"newSkill": "New Skill",
"noMatchesDescription": "Try a different search term.",
"noMatchesDescription": "Try a different search or filter.",
"noMatchesTitle": "No matching skills",
"optionsAria": "Options for {{name}}",
"searchPlaceholder": "Search skills by name or description...",
"title": "Skills"
"openDetails": "Open {{name}} details",
"projects": "Projects",
"reveal": "Show in folder",
"searchPlaceholder": "Search skills",
"share": "Share",
"skillCount_one": "{{displayCount}} skill",
"skillCount_other": "{{displayCount}} skills",
"source": "Source",
"startChat": "Start chat with {{name}}",
"startChatShort": "Start chat",
"title": "Skills",
"useInChat": "Use in chat"
}
}
@@ -15,18 +15,54 @@
"saving": "Guardando..."
},
"view": {
"backToSkills": "Volver a skills",
"category": "Categoría",
"categories": {
"clear": "Limpiar categorías",
"count": "{{count}} categorías",
"filter": "Filtrar por categoría",
"label": "Categorías",
"options": {
"design": "Diseño",
"engineering": "Ingeniería",
"general": "General",
"integrations": "Integraciones",
"operations": "Operaciones",
"productivity": "Productividad",
"quality": "Calidad",
"research": "Investigación",
"writing": "Redacción"
}
},
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
"deleteTitle": "¿Eliminar skill?",
"description": "Instrucciones reutilizables para tus agentes de IA",
"description": "Las skills son instrucciones reutilizables que ayudan a un agente a manejar tareas, flujos de trabajo o herramientas específicas.",
"detailEmptyDescription": "Selecciona una skill para inspeccionar su origen, ubicación e instrucciones.",
"detailEmptyTitle": "Elige una skill",
"dropFile": "o suelta un archivo",
"emptyDescription": "Crea una skill o suelta aquí un archivo .skill.json.",
"emptyDescription": "Crea una skill o importa una para empezar.",
"emptyTitle": "Aún no hay skills",
"exportedTo": "Exportado a {{filename}}",
"filePath": "Ruta del archivo",
"filtersAllSources": "Todas",
"filtersGlobal": "Personal",
"instructions": "Instrucciones",
"location": "Ubicación",
"more": "Más",
"newSkill": "Nueva skill",
"noMatchesDescription": "Prueba con otro término de búsqueda.",
"noMatchesDescription": "Prueba con otra búsqueda o filtro.",
"noMatchesTitle": "No hay skills que coincidan",
"optionsAria": "Opciones de {{name}}",
"searchPlaceholder": "Buscar skills por nombre o descripción...",
"title": "Skills"
"openDetails": "Abrir detalles de {{name}}",
"projects": "Proyectos",
"reveal": "Mostrar en carpeta",
"searchPlaceholder": "Buscar skills",
"share": "Compartir",
"skillCount_one": "{{displayCount}} skill",
"skillCount_other": "{{displayCount}} skills",
"source": "Origen",
"startChat": "Iniciar chat con {{name}}",
"startChatShort": "Iniciar chat",
"title": "Skills",
"useInChat": "Usar en chat"
}
}
+39
View File
@@ -102,6 +102,12 @@
/* Semantic borders */
--border-default: var(--color-gray-200);
--border-soft: color-mix(in srgb, var(--border-default) 80%, transparent);
--border-soft-divider: color-mix(
in srgb,
var(--border-default) 70%,
transparent
);
--border-input: var(--color-gray-300);
--border-input-hover: var(--color-gray-400);
--border-strong: var(--color-gray-900);
@@ -285,6 +291,12 @@
/* semantic borders */
--border-default: var(--color-gray-700);
--border-soft: color-mix(in srgb, var(--border-default) 80%, transparent);
--border-soft-divider: color-mix(
in srgb,
var(--border-default) 70%,
transparent
);
--border-input: var(--color-gray-700);
--border-input-hover: var(--color-gray-600);
--border-strong: var(--color-white);
@@ -390,6 +402,8 @@
/* semantic borders */
--color-border-default: var(--border-default);
--color-border-soft: var(--border-soft);
--color-border-soft-divider: var(--border-soft-divider);
--color-border-input: var(--border-input);
--color-border-input-hover: var(--border-input-hover);
--color-border-strong: var(--border-strong);
@@ -487,6 +501,9 @@
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-accordion-content-open: accordion-content-open 0.2s
cubic-bezier(0.25, 1, 0.5, 1) both;
--animate-accordion-content-close: accordion-content-close 0.2s ease-out both;
--animate-caret-blink: caret-blink 1s ease-out infinite;
--animate-scale-in: scale-in 0.2s ease;
--animate-scale-out: scale-out 0.15s ease;
@@ -516,6 +533,28 @@
}
}
@keyframes accordion-content-open {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes accordion-content-close {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-2px);
}
}
@keyframes caret-blink {
0%,
70%,
+10 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/cn";
/**
* Layout wrapper for full-page views (Skills, Agents, etc.).
@@ -9,12 +10,20 @@ import type { ReactNode } from "react";
export function MainPanelLayout({
children,
backgroundColor = "bg-background",
className,
}: {
children: ReactNode;
backgroundColor?: string;
className?: string;
}) {
return (
<div className={`flex flex-col ${backgroundColor} min-w-0 h-full min-h-0`}>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-col",
backgroundColor,
className,
)}
>
{children}
</div>
);
+36 -3
View File
@@ -3,6 +3,24 @@ import { Search } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
const searchBarSizes = {
small: {
wrapper:
"rounded-md border border-border-soft px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground",
icon: "left-3 size-3.5",
input:
"h-auto border-none bg-transparent px-0 pl-6 pr-0 text-xs font-normal shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0",
inputVariant: "ghost" as const,
},
default: {
wrapper: "",
icon: "left-3 size-4",
input:
"rounded-lg border-border-soft bg-background pr-3 pl-9 text-sm font-normal hover:border-border-soft focus-visible:border-ring",
inputVariant: "default" as const,
},
} as const;
interface SearchBarProps {
/** Current search value (controlled) */
value: string;
@@ -14,6 +32,10 @@ interface SearchBarProps {
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
/** Optional className for the wrapper */
className?: string;
/** Size variant for the search field */
size?: keyof typeof searchBarSizes;
/** Optional ref for the underlying input */
inputRef?: React.Ref<HTMLInputElement>;
}
export function SearchBar({
@@ -22,11 +44,22 @@ export function SearchBar({
placeholder,
onKeyDown,
className,
size = "default",
inputRef,
}: SearchBarProps) {
const styles = searchBarSizes[size];
return (
<div className={cn("relative", className)}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className={cn("relative w-full", styles.wrapper, className)}>
<Search
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-placeholder",
styles.icon,
)}
/>
<Input
inputRef={inputRef}
variant={styles.inputVariant}
type="search"
autoComplete="off"
spellCheck={false}
@@ -34,7 +67,7 @@ export function SearchBar({
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
className="rounded-lg bg-background pr-3 pl-9 text-sm"
className={cn("w-full placeholder:text-placeholder", styles.input)}
/>
</div>
);
+60 -4
View File
@@ -26,20 +26,35 @@ function AccordionItem({
function AccordionTrigger({
className,
children,
indicatorClassName,
indicatorPosition = "end",
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
}: React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
indicatorClassName?: string;
indicatorPosition?: "start" | "end" | "none";
}) {
const indicator = (
<ChevronDownIcon
className={cn(
"pointer-events-none size-4 shrink-0 text-muted-foreground transition-[opacity,transform] duration-200 group-data-[state=open]/accordion-trigger:rotate-180",
indicatorClassName,
)}
/>
);
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm transition-all outline-none hover:underline focus-visible:ring-[1px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
"group/accordion-trigger focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm transition-all outline-none hover:underline focus-visible:ring-[1px] disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{indicatorPosition === "start" ? indicator : null}
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
{indicatorPosition === "end" ? indicator : null}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
@@ -61,4 +76,45 @@ function AccordionContent({
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
function AccordionSectionTrigger({
className,
title,
meta,
indicatorClassName,
...props
}: Omit<React.ComponentProps<typeof AccordionPrimitive.Trigger>, "children"> & {
title: React.ReactNode;
meta?: React.ReactNode;
indicatorClassName?: string;
}) {
return (
<AccordionTrigger
indicatorPosition="none"
className={cn("px-5 py-4 text-left hover:no-underline", className)}
{...props}
>
<div className="flex flex-1 items-baseline justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-base font-normal text-foreground">{title}</p>
<ChevronDownIcon
className={cn(
"size-4 shrink-0 text-muted-foreground opacity-0 transition-[opacity,transform] duration-200 group-hover/accordion-trigger:opacity-100 group-focus-visible/accordion-trigger:opacity-100 group-data-[state=open]/accordion-trigger:rotate-180",
indicatorClassName,
)}
/>
</div>
{meta ? (
<div className="text-xs font-light text-muted-foreground">{meta}</div>
) : null}
</div>
</AccordionTrigger>
);
}
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionSectionTrigger,
AccordionTrigger,
};
+65
View File
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { IconArrowNarrowLeft } from "@tabler/icons-react";
import { Button } from "./button";
describe("Button", () => {
it("applies the button size to unsized icons", () => {
render(
<Button size="sm" leftIcon={<IconArrowNarrowLeft data-testid="icon" />}>
Back
</Button>,
);
expect(screen.getByTestId("icon")).toHaveClass("size-3");
});
it("preserves an explicit icon class size", () => {
render(
<Button
size="sm"
leftIcon={<IconArrowNarrowLeft data-testid="icon" className="size-4" />}
>
Back
</Button>,
);
expect(screen.getByTestId("icon")).toHaveClass("size-4");
expect(screen.getByTestId("icon")).not.toHaveClass("size-3");
});
it("preserves an explicit icon size prop", () => {
render(
<Button
size="sm"
leftIcon={<IconArrowNarrowLeft data-testid="icon" size={18} />}
>
Back
</Button>,
);
expect(screen.getByTestId("icon")).toHaveAttribute("width", "18");
expect(screen.getByTestId("icon")).toHaveAttribute("height", "18");
expect(screen.getByTestId("icon")).not.toHaveClass("size-3");
});
it("renders the back variant with its default chevron icon", () => {
render(
<Button variant="back" size="sm">
Back
</Button>,
);
const button = screen.getByRole("button", { name: "Back" });
const icon = button.querySelector("svg");
expect(button).toHaveClass(
"h-8",
"px-3",
"text-xs",
"text-muted-foreground",
);
expect(icon).not.toBeNull();
expect(icon).toHaveClass("size-3");
});
});
+87 -30
View File
@@ -1,11 +1,12 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { IconChevronLeft } from "@tabler/icons-react";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-left text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-left text-sm font-normal transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -18,29 +19,28 @@ const buttonVariants = cva(
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"outline-flat":
"border border-input bg-background shadow-none hover:bg-accent hover:text-accent-foreground",
"border border-border-soft bg-background shadow-none hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
"ghost-light":
"font-normal hover:bg-accent hover:text-accent-foreground",
"inline-subtle":
"rounded-md bg-transparent font-normal text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
toolbar:
"justify-start bg-transparent font-normal text-foreground shadow-none hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground",
back: "justify-start text-muted-foreground hover:text-foreground",
link: "text-brand underline-offset-4 hover:underline",
},
size: {
xs: "h-7 px-2.5 text-xs [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3",
default:
"h-9 px-4 py-2 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3.5",
sm: "h-8 px-3 text-xs [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3",
lg: "h-10 px-8 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-4",
icon: "h-9 w-9 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-4",
"icon-xs":
"h-7 w-7 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3",
"icon-sm":
"h-8 w-8 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3.5",
"icon-lg":
"h-10 w-10 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-5",
xs: "h-7 px-2.5 text-xs",
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
icon: "h-9 w-9",
"icon-xs": "h-7 w-7",
"icon-sm": "h-8 w-8",
"icon-lg": "h-10 w-10",
},
},
compoundVariants: [
@@ -59,6 +59,11 @@ const buttonVariants = cva(
size: "default",
className: "gap-1.5 px-2.5 text-[13px]",
},
{
variant: "inline-subtle",
size: "xs",
className: "h-6 gap-1.5 px-2 text-[11px]",
},
{
variant: "ghost",
size: "icon-xs",
@@ -91,6 +96,67 @@ const buttonVariants = cva(
},
);
const buttonIconSizeClasses = {
xs: "size-3",
default: "size-3.5",
sm: "size-3",
lg: "size-4",
icon: "size-4",
"icon-xs": "size-3",
"icon-sm": "size-3.5",
"icon-lg": "size-5",
} satisfies Record<
NonNullable<VariantProps<typeof buttonVariants>["size"]>,
string
>;
type ButtonIconProps = {
className?: string;
size?: number | string;
width?: number | string;
height?: number | string;
style?: React.CSSProperties;
};
function hasExplicitIconDimensions(icon: React.ReactElement<ButtonIconProps>) {
return (
icon.props.size !== undefined ||
icon.props.width !== undefined ||
icon.props.height !== undefined ||
icon.props.style?.width !== undefined ||
icon.props.style?.height !== undefined
);
}
function renderButtonIcon(
icon: React.ReactNode,
slot: "button-left-icon" | "button-right-icon",
size: VariantProps<typeof buttonVariants>["size"],
) {
if (!icon) {
return null;
}
const iconSizeClass = buttonIconSizeClasses[size ?? "default"];
const content =
React.isValidElement<ButtonIconProps>(icon) &&
icon.type !== React.Fragment &&
!hasExplicitIconDimensions(icon)
? React.cloneElement(icon, {
className: cn(iconSizeClass, icon.props.className),
})
: icon;
return (
<span
data-slot={slot}
className="inline-flex shrink-0 items-center justify-center"
>
{content}
</span>
);
}
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
@@ -113,6 +179,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref,
) => {
const Comp = asChild ? Slot : "button";
const resolvedLeftIcon =
variant === "back"
? (leftIcon ?? <IconChevronLeft aria-hidden="true" />)
: leftIcon;
return (
<Comp
data-slot="button"
@@ -124,23 +195,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
children
) : (
<>
{leftIcon ? (
<span
data-slot="button-left-icon"
className="inline-flex shrink-0 items-center justify-center"
>
{leftIcon}
</span>
) : null}
{renderButtonIcon(resolvedLeftIcon, "button-left-icon", size)}
{children}
{rightIcon ? (
<span
data-slot="button-right-icon"
className="inline-flex shrink-0 items-center justify-center"
>
{rightIcon}
</span>
) : null}
{renderButtonIcon(rightIcon, "button-right-icon", size)}
</>
)}
</Comp>
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/cn";
interface DetailFieldProps {
label: ReactNode;
children?: ReactNode;
meta?: ReactNode;
className?: string;
headerClassName?: string;
labelClassName?: string;
contentClassName?: string;
contentAs?: "div" | "p";
}
export function DetailField({
label,
children,
meta,
className,
headerClassName,
labelClassName,
contentClassName,
contentAs = "div",
}: DetailFieldProps) {
const Content = contentAs;
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
"flex items-center justify-between gap-3",
headerClassName,
)}
>
<p
className={cn(
"text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground",
labelClassName,
)}
>
{label}
</p>
{meta}
</div>
{children !== undefined && children !== null ? (
<Content className={cn("text-sm text-foreground", contentClassName)}>
{children}
</Content>
) : null}
</div>
);
}
+9 -1
View File
@@ -14,11 +14,19 @@ const variantStyles = {
interface InputProps extends React.ComponentProps<"input"> {
variant?: keyof typeof variantStyles;
inputRef?: React.Ref<HTMLInputElement>;
}
function Input({ className, type, variant = "default", ...props }: InputProps) {
function Input({
className,
type,
variant = "default",
inputRef,
...props
}: InputProps) {
return (
<input
ref={inputRef}
type={type}
data-slot="input"
className={cn(...variantStyles[variant], className)}
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PageColumns } from "./page-columns";
let mockIsMobile = false;
vi.mock("@/hooks/use-mobile", () => ({
useIsMobile: () => mockIsMobile,
}));
describe("PageColumns", () => {
beforeEach(() => {
mockIsMobile = false;
});
it("renders a resizable desktop split layout", () => {
render(
<PageColumns sidebar={<div>Metadata</div>}>
<div>Instructions</div>
</PageColumns>,
);
expect(screen.getByText("Metadata")).toBeInTheDocument();
expect(screen.getByText("Instructions")).toBeInTheDocument();
expect(screen.getByRole("separator")).toBeInTheDocument();
});
it("falls back to a stacked layout on mobile", () => {
mockIsMobile = true;
render(
<PageColumns sidebar={<div>Metadata</div>}>
<div>Instructions</div>
</PageColumns>,
);
expect(screen.getByText("Metadata")).toBeInTheDocument();
expect(screen.getByText("Instructions")).toBeInTheDocument();
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { useId, type CSSProperties, type ReactNode } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/shared/ui/resizable";
interface PageColumnsProps {
sidebar: ReactNode;
children: ReactNode;
className?: string;
sidebarClassName?: string;
contentClassName?: string;
resizable?: boolean;
defaultSidebarSize?: number;
minSidebarSize?: number;
maxSidebarSize?: number;
minContentSize?: number;
}
export function PageColumns({
sidebar,
children,
className,
sidebarClassName,
contentClassName,
resizable = true,
defaultSidebarSize = 28,
minSidebarSize = 22,
maxSidebarSize = 38,
minContentSize = 48,
}: PageColumnsProps) {
const isMobile = useIsMobile();
const pageColumnsId = useId();
const sidebarSize = `${defaultSidebarSize}%`;
const sidebarMinSize = `${minSidebarSize}%`;
const sidebarMaxSize = `${maxSidebarSize}%`;
const contentSize = `${100 - defaultSidebarSize}%`;
const contentMinSize = `${minContentSize}%`;
const stackedLayoutStyle = {
"--page-columns-sidebar-size": sidebarSize,
} as CSSProperties;
const panelContentStyle = {
overflow: "visible",
maxHeight: "none",
flexGrow: 0,
} as CSSProperties;
if (isMobile || !resizable) {
return (
<div
className={cn(
"grid gap-10 lg:grid-cols-[minmax(0,var(--page-columns-sidebar-size))_minmax(0,1fr)]",
className,
)}
style={stackedLayoutStyle}
>
<div className={cn("min-w-0", sidebarClassName)}>{sidebar}</div>
<div className={cn("min-w-0", contentClassName)}>{children}</div>
</div>
);
}
return (
<ResizablePanelGroup
orientation="horizontal"
resizeTargetMinimumSize={{ coarse: 36, fine: 12 }}
className={cn("min-h-0 min-w-0 items-stretch", className)}
style={{ height: "auto", overflow: "visible" }}
>
<ResizablePanel
id={`${pageColumnsId}-sidebar`}
defaultSize={sidebarSize}
minSize={sidebarMinSize}
maxSize={sidebarMaxSize}
className="min-w-0 overflow-visible"
style={panelContentStyle}
>
<div className={cn("min-w-0 pr-5", sidebarClassName)}>{sidebar}</div>
</ResizablePanel>
<ResizableHandle
withHandle
variant="subtle"
indicator="arrows"
className="data-[separator=active]:bg-border-default hover:bg-border"
/>
<ResizablePanel
id={`${pageColumnsId}-content`}
defaultSize={contentSize}
minSize={contentMinSize}
className="min-w-0 overflow-visible"
style={panelContentStyle}
>
<div className={cn("min-w-0 pl-5", contentClassName)}>{children}</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
+146
View File
@@ -0,0 +1,146 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/cn";
import { MainPanelLayout } from "./MainPanelLayout";
interface ShellProps {
children: ReactNode;
className?: string;
contentClassName?: string;
}
interface PageHeaderProps {
eyebrow?: ReactNode;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
titleElement?: "h1" | "div";
variant?: "default" | "detail";
actionsPlacement?: "end" | "below";
className?: string;
eyebrowClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
actionsClassName?: string;
}
export function PageShell({
children,
className,
contentClassName,
}: ShellProps) {
return (
<MainPanelLayout className={className}>
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-8 page-transition",
contentClassName,
)}
>
{children}
</div>
</div>
</MainPanelLayout>
);
}
export function DetailPageShell({
children,
className,
contentClassName,
}: ShellProps) {
return (
<MainPanelLayout className={className}>
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-8 page-transition",
contentClassName,
)}
>
{children}
</div>
</div>
</MainPanelLayout>
);
}
export function PageHeader({
eyebrow,
title,
description,
actions,
titleElement = "h1",
variant = "default",
actionsPlacement = "end",
className,
eyebrowClassName,
titleClassName,
descriptionClassName,
actionsClassName,
}: PageHeaderProps) {
const TitleElement = titleElement;
const actionsBelow = actionsPlacement === "below";
const titleVariantClassName =
variant === "detail"
? "font-display text-2xl font-normal tracking-tight text-foreground"
: "text-xl tracking-tight";
return (
<div
className={cn(
"flex flex-wrap items-start justify-between gap-4",
actionsBelow && "flex-col items-start justify-start",
className,
)}
>
<div className="min-w-0">
{eyebrow ? (
<div className={cn("mb-3", eyebrowClassName)}>{eyebrow}</div>
) : null}
<TitleElement className={cn(titleVariantClassName, titleClassName)}>
{title}
</TitleElement>
{description ? (
<p
className={cn(
"mt-1 text-sm font-light text-muted-foreground",
descriptionClassName,
)}
>
{description}
</p>
) : null}
{actions && actionsBelow ? (
<div
className={cn(
"mt-4 flex flex-wrap items-center gap-1",
actionsClassName,
)}
>
{actions}
</div>
) : null}
</div>
{actions && !actionsBelow ? (
<div className={cn("flex items-start gap-2", actionsClassName)}>
{actions}
</div>
) : null}
</div>
);
}
export function FilterRow({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{children}
</div>
);
}
+29 -8
View File
@@ -1,5 +1,5 @@
import type * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import { IconArrowsHorizontal, IconGripVertical } from "@tabler/icons-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/shared/lib/cn";
@@ -11,10 +11,7 @@ function ResizablePanelGroup({
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
className={cn("flex min-h-0 w-full", className)}
{...props}
/>
);
@@ -28,23 +25,47 @@ function ResizablePanel({
function ResizableHandle({
withHandle,
variant = "default",
indicator = "grip",
className,
handleClassName,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
variant?: "default" | "subtle";
indicator?: "grip" | "arrows";
handleClassName?: string;
}) {
const indicatorIcon =
indicator === "arrows" ? (
<IconArrowsHorizontal className="size-2.5" />
) : (
<IconGripVertical className="size-2.5" />
);
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border-default focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"group focus-visible:ring-ring relative flex w-px shrink-0 items-center justify-center focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden after:absolute after:inset-y-0 after:left-1/2 after:-translate-x-1/2 aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:top-1/2 aria-[orientation=horizontal]:after:-translate-y-1/2 aria-[orientation=horizontal]:after:translate-x-0 [&[aria-orientation=horizontal]>div]:rotate-90",
variant === "subtle"
? "bg-transparent transition-colors after:w-5 hover:bg-border data-[separator=active]:bg-border-default focus-visible:bg-border-default aria-[orientation=horizontal]:after:h-5 aria-[orientation=horizontal]:after:w-full"
: "bg-border-default after:w-1 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border-default z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
<div
className={cn(
"z-10 flex items-center justify-center border text-muted-foreground",
variant === "subtle"
? "bg-background/95 shadow-xs h-5 w-5 rounded-full border-border-soft opacity-0 transition-all duration-150 ease-out group-hover:scale-100 group-hover:opacity-100 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-data-[separator=active]:scale-100 group-data-[separator=active]:opacity-100 scale-95"
: "bg-border-default h-4 w-3 rounded-xs",
handleClassName,
)}
>
{indicatorIcon}
</div>
)}
</ResizablePrimitive.Separator>