redesign skills library (#8868)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user