feat(frontend): new run modal components (#10729)

## Changes 🏗️

Add components needed for the new **Agent Run Modal** ( _splitting PRs
in this way the modal PR will be smaller_ 💆🏽 ).
[Design
reference](https://www.figma.com/design/14jjs3hH3Hmkq4hGqxZWco/agent-runs-unification?node-id=188-15511&t=6fVja182TuoluMwc-1).

### `<Breadcrumbs />`

<img width="248" height="72" alt="Screenshot 2025-08-25 at 17 52 36"
src="https://github.com/user-attachments/assets/6191aa03-bb6b-47fe-af8c-20dbdb1b9d06"
/>

Before the project was using Breadcrumbs from Shadcn directly, it now
uses new ones styled following the AutoGPT Design System.

### `<MultiToggle />` 

<img width="350" height="148" alt="Screenshot 2025-08-25 at 17 52 07"
src="https://github.com/user-attachments/assets/e1bbb735-62e5-4c73-929a-52ec0109f274"
/>

### `<Collapisble />`

<img width="350" height="135" alt="Screenshot 2025-08-25 at 17 52 50"
src="https://github.com/user-attachments/assets/e4ee4026-8bd5-4d08-8875-3ecb573bd6bb"
/>

### `<ShowMoreText />`

<img width="500" height="60" alt="Screenshot 2025-08-25 at 17 52 17"
src="https://github.com/user-attachments/assets/2e85a192-b7ab-4f5f-b35d-5ed9d3ef6132"
/>

This is very similar to `<Collapsible />`, the difference is designed to
work specifically with text. The `more/less` trigger is displayed next
to the text in a simple way. `<Collapsible />` might used with other
elements, for example like FAQ or hiding/expanding forms.

### `<LLMItem />`

<img width="300" height="78" alt="Screenshot 2025-08-25 at 17 52 26"
src="https://github.com/user-attachments/assets/7b904e15-75d3-4f11-9863-2d0db072e884"
/>

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run Storybook
  - [x] Stories look good and make sense 

#### For configuration changes:

None
This commit is contained in:
Ubbe
2025-08-25 11:29:55 +01:00
committed by GitHub
parent 476bfc6c84
commit 8a9c165faf
16 changed files with 1108 additions and 3 deletions

View File

@@ -11,7 +11,7 @@ interface BadgeProps {
const badgeVariants: Record<BadgeVariant, string> = {
success: "bg-green-100 text-green-800",
error: "bg-red-100 text-red-800",
info: "bg-slate-100 text-slate-800",
info: "bg-slate-50 text-black",
};
export function Badge({ variant, children, className }: BadgeProps) {
@@ -21,7 +21,7 @@ export function Badge({ variant, children, className }: BadgeProps) {
// Base styles from Figma
"inline-flex items-center gap-2 rounded-[45px] px-[9px] py-[3px]",
// Text styles
"font-['Geist'] text-xs font-medium leading-5",
"font-sans text-[0.6785rem] font-medium uppercase leading-5 tracking-wider",
// Text overflow handling
"overflow-hidden text-ellipsis",
// Variant styles

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { LLMItem } from "./LLMItem";
const meta: Meta<typeof LLMItem> = {
title: "Atoms/LLMItem",
tags: ["autodocs"],
component: LLMItem,
parameters: {
layout: "centered",
docs: {
description: {
component:
"LLMItem component for displaying different LLM providers with their respective icons and names.",
},
},
},
argTypes: {
type: {
control: "select",
options: ["claude", "gpt", "perplexity"],
description: "LLM provider type",
},
},
args: {
type: "claude",
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Claude: Story = {
args: {
type: "claude",
},
};
export const GPT: Story = {
args: {
type: "gpt",
},
};
export const Perplexity: Story = {
args: {
type: "perplexity",
},
};
export const AllVariants: Story = {
render: renderAllVariants,
};
function renderAllVariants() {
return (
<div className="flex flex-wrap gap-8">
<LLMItem type="claude" />
<LLMItem type="gpt" />
<LLMItem type="perplexity" />
</div>
);
}

View File

@@ -0,0 +1,41 @@
import Image from "next/image";
import { Text } from "@/components/atoms/Text/Text";
import claudeImg from "./assets/claude.svg";
import gptImg from "./assets/gpt.svg";
import perplexityImg from "./assets/perplexity.svg";
type LLMType = "claude" | "gpt" | "perplexity";
const llmTypeMap: Record<LLMType, { image: string; name: string }> = {
claude: {
image: claudeImg.src,
name: "Claude",
},
gpt: {
image: gptImg.src,
name: "GPT",
},
perplexity: {
image: perplexityImg.src,
name: "Perplexity",
},
};
type Props = {
type: LLMType;
};
export function LLMItem({ type }: Props) {
return (
<div className="flex flex-nowrap items-center gap-2">
<Image
src={llmTypeMap[type].image}
alt={llmTypeMap[type].name}
width={40}
height={40}
className="h-5 w-5 rounded-xsmall"
/>
<Text variant="body">{llmTypeMap[type].name}</Text>
</div>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,132 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { Breadcrumbs } from "./Breadcrumbs";
const meta: Meta<typeof Breadcrumbs> = {
title: "Molecules/Breadcrumbs",
component: Breadcrumbs,
parameters: {
layout: "centered",
docs: {
description: {
component: `
## Breadcrumbs Component
A navigation breadcrumb component that shows the current page's location within a navigational hierarchy.
### ✨ Features
- **Clean design** - Simple, elegant breadcrumb navigation
- **Link integration** - Uses custom Link component for navigation
- **Responsive** - Flexible layout that wraps on smaller screens
- **Accessible** - Semantic markup with proper link structure
- **TypeScript support** - Full TypeScript interface support
- **Customizable** - Easy to style and extend
### 🎯 Usage
\`\`\`tsx
<Breadcrumbs
items={[
{ name: "Home", link: "/" },
{ name: "Library", link: "/library" },
{ name: "Agents", link: "/library/agents" },
]}
/>
\`\`\`
### Props
- **items**: Array of breadcrumb items with name and link properties
### BreadcrumbItem Interface
\`\`\`tsx
interface BreadcrumbItem {
name: string; // Display text for the breadcrumb
link: string; // URL to navigate to when clicked
}
\`\`\`
`,
},
},
},
tags: ["autodocs"],
argTypes: {
items: {
control: "object",
description: "Array of breadcrumb items with name and link properties",
table: {
type: {
summary: "BreadcrumbItem[]",
detail: "Array of { name: string, link: string }",
},
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Basic breadcrumb navigation with a few levels.
*/
export const Default: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Library", link: "/library" },
{ name: "Agents", link: "/library/agents" },
],
},
};
/**
* Single breadcrumb item (just home).
*/
export const Single: Story = {
args: {
items: [{ name: "Home", link: "/" }],
},
};
/**
* Two-level breadcrumb navigation.
*/
export const TwoLevels: Story = {
args: {
items: [
{ name: "Dashboard", link: "/dashboard" },
{ name: "Settings", link: "/dashboard/settings" },
],
},
};
/**
* Deep navigation with many levels.
*/
export const DeepNavigation: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Platform", link: "/platform" },
{ name: "Library", link: "/platform/library" },
{ name: "Agents", link: "/platform/library/agents" },
{ name: "My Agent", link: "/platform/library/agents/123" },
{ name: "Edit", link: "/platform/library/agents/123/edit" },
],
},
};
/**
* Breadcrumbs with longer text to show wrapping behavior.
*/
export const LongText: Story = {
args: {
items: [
{ name: "AutoGPT Platform", link: "/" },
{ name: "Agent Library Management", link: "/library" },
{ name: "Advanced Configuration Settings", link: "/library/settings" },
],
},
};

View File

@@ -0,0 +1,155 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { Collapsible } from "./Collapsible";
const meta: Meta<typeof Collapsible> = {
title: "Molecules/Collapsible",
component: Collapsible,
parameters: {
layout: "centered",
docs: {
description: {
component: `
## Collapsible Component
A reusable collapsible component built on top of shadcn's collapsible primitives with enhanced functionality and styling.
### ✨ Features
- **Built on shadcn base** - Uses shadcn collapsible primitives without modification
- **Custom trigger design** - Enhanced trigger with "↓ more" / "↑ less" text indicators
- **Smooth animations** - Chevron rotation and content expand/collapse transitions
- **Controlled & uncontrolled modes** - Supports both controlled and uncontrolled usage
- **Customizable styling** - Props for custom classes on trigger, content, and root
- **Accessible** - Built on Radix UI primitives for full accessibility support
- **TypeScript support** - Complete TypeScript interface support
### 🎯 Usage
\`\`\`tsx
<Collapsible
trigger={<span>Click to expand</span>}
defaultOpen={false}
>
<p>This content will be collapsed/expanded</p>
</Collapsible>
\`\`\`
### Props
- **trigger**: React node to display as the clickable trigger
- **children**: Content to show/hide when collapsing/expanding
- **defaultOpen**: Initial open state (uncontrolled mode)
- **open**: Current open state (controlled mode)
- **onOpenChange**: Callback when open state changes
- **className**: Additional classes for the root container
- **triggerClassName**: Additional classes for the trigger element
- **contentClassName**: Additional classes for the content container
`,
},
},
},
tags: ["autodocs"],
argTypes: {
trigger: {
control: false,
description: "The trigger element to click for expanding/collapsing",
},
children: {
control: false,
description: "Content to show when expanded",
},
defaultOpen: {
control: "boolean",
description: "Initial open state (uncontrolled mode)",
table: {
defaultValue: { summary: "false" },
},
},
open: {
control: "boolean",
description: "Current open state (controlled mode)",
},
onOpenChange: {
control: false,
description: "Callback function when open state changes",
},
className: {
control: "text",
description: "Additional CSS classes for root container",
},
triggerClassName: {
control: "text",
description: "Additional CSS classes for trigger element",
},
contentClassName: {
control: "text",
description: "Additional CSS classes for content container",
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default collapsible with simple text trigger and content.
*/
export const Default: Story = {
args: {
trigger: <span className="font-medium">Click to expand</span>,
children: (
<div className="space-y-2">
<p>
This is the collapsible content that can be expanded or collapsed.
</p>
<p>You can put any React elements here.</p>
</div>
),
defaultOpen: false,
},
};
/**
* Collapsible that starts in the expanded state.
*/
export const DefaultOpen: Story = {
args: {
trigger: <span className="font-medium">Already expanded</span>,
children: (
<div className="space-y-2">
<p>This collapsible starts in the open state.</p>
<p>Notice how the chevron and text indicators reflect this.</p>
</div>
),
defaultOpen: true,
},
};
/**
* Multiple collapsibles can be used together to create accordion-like interfaces.
*/
export const MultipleCollapsibles: Story = {
render: () => (
<div className="w-96 space-y-4">
<Collapsible
trigger={<span className="font-medium">Section 1</span>}
className="p-3"
>
<p>Content for the first section.</p>
</Collapsible>
<Collapsible
trigger={<span className="font-medium">Section 2</span>}
className="p-3"
defaultOpen={true}
>
<p>Content for the second section, which starts expanded.</p>
</Collapsible>
<Collapsible
trigger={<span className="font-medium">Section 3</span>}
className="p-3"
>
<p>Content for the third section.</p>
</Collapsible>
</div>
),
};

View File

@@ -0,0 +1,73 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
Collapsible as BaseCollapsible,
CollapsibleTrigger as BaseCollapsibleTrigger,
CollapsibleContent as BaseCollapsibleContent,
} from "@/components/ui/collapsible";
import { CaretDownIcon } from "@phosphor-icons/react";
interface Props {
trigger: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
triggerClassName?: string;
contentClassName?: string;
}
export function Collapsible({
trigger,
children,
defaultOpen = false,
open,
onOpenChange,
className,
triggerClassName,
contentClassName,
}: Props) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const isControlled = open !== undefined;
const openState = isControlled ? open : isOpen;
const handleOpenChange = (newOpen: boolean) => {
if (!isControlled) {
setIsOpen(newOpen);
}
onOpenChange?.(newOpen);
};
return (
<BaseCollapsible
open={openState}
onOpenChange={handleOpenChange}
className={cn("w-full", className)}
>
<BaseCollapsibleTrigger
className={cn(
"flex w-full items-center justify-between text-left transition-all duration-200 hover:opacity-80",
triggerClassName,
)}
>
<div className="flex-end flex flex-wrap items-center gap-2">
{trigger}
<CaretDownIcon
className={cn(
"inline-flex h-4 w-4 transition-transform duration-200",
openState && "rotate-180",
)}
/>
</div>
</BaseCollapsibleTrigger>
<BaseCollapsibleContent
className={cn("overflow-hidden", contentClassName)}
>
<div className="pt-2">{children}</div>
</BaseCollapsibleContent>
</BaseCollapsible>
);
}

View File

@@ -4,7 +4,7 @@ const commonStyles = {
overlay:
"fixed inset-0 z-50 bg-stone-500/20 dark:bg-black/50 backdrop-blur-md animate-fade-in",
content:
"bg-white p-6 fixed rounded-xl flex flex-col z-50 w-full overflow-hidden",
"bg-white p-6 fixed rounded-2xlarge flex flex-col z-50 w-full overflow-hidden",
};
// Modal specific styles

View File

@@ -0,0 +1,129 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { MultiToggle } from "./MultiToggle";
const meta: Meta<typeof MultiToggle> = {
title: "Molecules/MultiToggle",
tags: ["autodocs"],
component: MultiToggle,
parameters: {
layout: "centered",
docs: {
description: {
component:
"MultiToggle component that behaves like a checkbox group, allowing multiple items to be selected. Each item uses outline button styling with purple-600 accent for selected state.",
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
const weekdayItems = [
{ value: "select-all", label: "Select all" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekends", label: "Weekends" },
];
const dayItems = [
{ value: "su", label: "Su" },
{ value: "mo", label: "Mo" },
{ value: "tu", label: "Tu" },
{ value: "we", label: "We" },
{ value: "th", label: "Th" },
{ value: "fr", label: "Fr" },
{ value: "sa", label: "Sa" },
];
export const WeekdaySelector: Story = {
render: function WeekdaySelectorStory() {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">On</span>
<MultiToggle
items={weekdayItems}
selectedValues={selectedValues}
onChange={setSelectedValues}
aria-label="Select scheduling options"
/>
</div>
<MultiToggle
items={dayItems}
selectedValues={selectedValues}
onChange={setSelectedValues}
aria-label="Select specific days"
/>
</div>
);
},
};
export const SimpleExample: Story = {
render: function SimpleExampleStory() {
const [selectedValues, setSelectedValues] = useState<string[]>(["option2"]);
return (
<MultiToggle
items={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
selectedValues={selectedValues}
onChange={setSelectedValues}
aria-label="Select options"
/>
);
},
};
export const WithDisabledItems: Story = {
render: function WithDisabledItemsStory() {
const [selectedValues, setSelectedValues] = useState<string[]>([
"enabled1",
]);
return (
<MultiToggle
items={[
{ value: "enabled1", label: "Enabled 1" },
{ value: "disabled1", label: "Disabled 1", disabled: true },
{ value: "enabled2", label: "Enabled 2" },
{ value: "disabled2", label: "Disabled 2", disabled: true },
]}
selectedValues={selectedValues}
onChange={setSelectedValues}
aria-label="Select options with some disabled"
/>
);
},
};
export const AllSelected: Story = {
render: function AllSelectedStory() {
const [selectedValues, setSelectedValues] = useState<string[]>([
"option1",
"option2",
"option3",
"option4",
]);
return (
<MultiToggle
items={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
{ value: "option4", label: "Option 4" },
]}
selectedValues={selectedValues}
onChange={setSelectedValues}
aria-label="All options selected"
/>
);
},
};

View File

@@ -0,0 +1,89 @@
"use client";
import { cn } from "@/lib/utils";
import React, { useId } from "react";
type MultiToggleItem = {
value: string;
label: string;
disabled?: boolean;
};
type MultiToggleProps = {
items: MultiToggleItem[];
selectedValues: string[];
onChange: (selectedValues: string[]) => void;
className?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export function MultiToggle({
items,
selectedValues,
onChange,
className,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: MultiToggleProps) {
const groupId = useId();
function handleToggle(value: string) {
if (selectedValues.includes(value)) {
onChange(selectedValues.filter((v) => v !== value));
} else {
onChange([...selectedValues, value]);
}
}
function handleKeyDown(event: React.KeyboardEvent, value: string) {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
handleToggle(value);
}
}
return (
<div
role="group"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn("flex flex-wrap gap-2", className)}
>
{items.map((item) => {
const isSelected = selectedValues.includes(item.value);
const itemId = `${groupId}-${item.value}`;
return (
<button
key={item.value}
id={itemId}
type="button"
role="checkbox"
aria-checked={isSelected}
disabled={item.disabled}
onClick={() => handleToggle(item.value)}
onKeyDown={(e) => handleKeyDown(e, item.value)}
className={cn(
// Base button styles similar to outline variant
"inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-600 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"rounded-full border font-sans",
"h-[2.25rem] px-4 py-2 text-sm leading-[22px]",
// Default outline styles
"border-zinc-700 bg-transparent text-black hover:bg-zinc-100",
// Selected styles with purple-600
isSelected &&
"border-purple-600 bg-purple-50 text-purple-600 hover:bg-purple-100",
// Disabled styles
item.disabled && "border-zinc-200 text-zinc-200 opacity-50",
)}
>
{item.label}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,286 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ShowMoreText } from "./ShowMoreText";
const meta: Meta<typeof ShowMoreText> = {
title: "Molecules/ShowMoreText",
component: ShowMoreText,
parameters: {
layout: "centered",
docs: {
description: {
component: `
## ShowMoreText Component
A simplified text truncation component that shows a preview of text content with an expand/collapse toggle functionality.
### ✨ Features
- **String content only** - Simplified to only accept string content
- **Text variant integration** - Uses Text component variants for consistent styling
- **Adaptive toggle sizing** - Toggle icons automatically size to match text variant
- **Smart truncation** - Automatically truncates text based on character limit
- **No heading variants** - Only supports body text variants (lead, large, body, small)
- **Inline toggle** - Toggle appears inline at the end of the text
- **TypeScript support** - Full TypeScript interface support
### 🎯 Usage
\`\`\`tsx
<ShowMoreText
variant="body"
previewLimit={150}
>
This is a long piece of text that will be truncated at the specified
character limit and show a "more" button to expand the full content.
</ShowMoreText>
\`\`\`
### Props
- **children**: String content to show/truncate
- **previewLimit**: Character limit for preview (default: 100)
- **variant**: Text variant to use (excludes heading variants)
- **className**: Additional classes for root container
- **previewClassName**: Additional classes applied in preview mode
- **expandedClassName**: Additional classes applied in expanded mode
- **toggleClassName**: Additional classes for the toggle button
- **defaultExpanded**: Whether to start in expanded state (default: false)
### Supported Text Variants
- **lead** - Large leading text
- **large**, **large-medium**, **large-semibold** - Large body text variants
- **body**, **body-medium** - Standard body text variants
- **small**, **small-medium** - Small text variants
`,
},
},
},
tags: ["autodocs"],
argTypes: {
children: {
control: "text",
description: "String content to show with truncation",
},
previewLimit: {
control: "number",
description: "Character limit for preview text",
table: {
defaultValue: { summary: "100" },
},
},
variant: {
control: "select",
options: [
"lead",
"large",
"large-medium",
"large-semibold",
"body",
"body-medium",
"small",
"small-medium",
],
description: "Text variant to use for styling",
table: {
defaultValue: { summary: "body" },
},
},
className: {
control: "text",
description: "Additional CSS classes for root container",
},
toggleClassName: {
control: "text",
description: "Additional CSS classes for the toggle button",
},
defaultExpanded: {
control: "boolean",
description: "Whether to start in expanded state",
table: {
defaultValue: { summary: "false" },
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Basic text truncation with default body variant and 100 character limit.
*/
export const Default: Story = {
args: {
children:
"This is a longer piece of text that will be truncated at the preview limit. When you click 'more', you'll see the full content. This demonstrates the basic functionality of the ShowMoreText component with plain text content.",
variant: "body",
previewLimit: 100,
},
};
/**
* Short text that doesn't need truncation - toggle won't appear.
*/
export const ShortText: Story = {
args: {
children: "This text is short enough that no truncation is needed.",
variant: "body",
previewLimit: 100,
},
};
/**
* Large leading text variant with custom preview limit.
*/
export const LeadVariant: Story = {
args: {
children:
"This example uses the lead text variant which is larger and more prominent. The toggle icons automatically scale to match the text size for a cohesive design.",
variant: "lead",
previewLimit: 80,
},
};
/**
* Large text variants demonstration.
*/
export const LargeVariants: Story = {
render: () => (
<div className="max-w-2xl space-y-4">
<ShowMoreText variant="large" previewLimit={60}>
Large variant: This demonstrates how the ShowMoreText component works
with the large text variant and how the toggle scales appropriately.
</ShowMoreText>
<ShowMoreText variant="large-medium" previewLimit={60}>
Large medium variant: This shows the medium weight version of the large
text variant with proper toggle sizing.
</ShowMoreText>
<ShowMoreText variant="large-semibold" previewLimit={60}>
Large semibold variant: This demonstrates the semibold version with
heavier font weight and matching toggle.
</ShowMoreText>
</div>
),
};
/**
* Body text variants demonstration.
*/
export const BodyVariants: Story = {
render: () => (
<div className="max-w-xl space-y-4">
<ShowMoreText variant="body" previewLimit={70}>
Body variant: This is the default text variant used for most content. It
provides good readability and spacing.
</ShowMoreText>
<ShowMoreText variant="body-medium" previewLimit={70}>
Body medium variant: This uses medium font weight for slightly more
emphasis while maintaining readability.
</ShowMoreText>
</div>
),
};
/**
* Small text variants demonstration.
*/
export const SmallVariants: Story = {
render: () => (
<div className="max-w-lg space-y-4">
<ShowMoreText variant="small" previewLimit={80}>
Small variant: This demonstrates the small text variant which is useful
for secondary information, captions, or footnotes where space is
limited.
</ShowMoreText>
<ShowMoreText variant="small-medium" previewLimit={80}>
Small medium variant: This uses the small size with medium font weight
for small text that needs slightly more emphasis.
</ShowMoreText>
</div>
),
};
/**
* Custom preview limit of 50 characters.
*/
export const CustomLimit: Story = {
args: {
children:
"This example shows how you can customize the preview limit to show more or less text in the initial preview before truncation occurs.",
variant: "body",
previewLimit: 50,
},
};
/**
* Component that starts in expanded state.
*/
export const DefaultExpanded: Story = {
args: {
children:
"This ShowMoreText component starts in the expanded state by default. You can click 'less' to collapse it to the preview mode. This is useful when you want to show the full content initially but still provide the option to collapse it.",
variant: "body",
previewLimit: 80,
defaultExpanded: true,
},
};
/**
* Custom styling for different states.
*/
export const CustomStyling: Story = {
args: {
children:
"This example demonstrates custom styling options. The preview state has a different background color, the expanded state has different padding, and the toggle button has custom styling to match your design system.",
variant: "body-medium",
previewLimit: 80,
className: "max-w-md",
toggleClassName: "text-blue-600 hover:text-blue-800",
},
};
/**
* Very long content to demonstrate with different text sizes.
*/
export const LongContent: Story = {
render: () => (
<div className="max-w-2xl space-y-6">
<div>
<h3 className="mb-2 text-sm font-medium text-gray-500">Lead Text</h3>
<ShowMoreText variant="lead" previewLimit={120}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</ShowMoreText>
</div>
<div>
<h3 className="mb-2 text-sm font-medium text-gray-500">Body Text</h3>
<ShowMoreText variant="body" previewLimit={120}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</ShowMoreText>
</div>
<div>
<h3 className="mb-2 text-sm font-medium text-gray-500">Small Text</h3>
<ShowMoreText variant="small" previewLimit={120}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</ShowMoreText>
</div>
</div>
),
};

View File

@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { getIconSize, ShowMoreTextVariant } from "./helpers";
interface Props {
children: string;
previewLimit?: number;
variant?: ShowMoreTextVariant;
className?: string;
toggleClassName?: string;
defaultExpanded?: boolean;
}
export function ShowMoreText({
children,
previewLimit = 100,
variant = "body",
className,
toggleClassName,
defaultExpanded = false,
}: Props) {
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
const shouldTruncate = children.length > previewLimit;
const previewText = shouldTruncate
? children.slice(0, previewLimit)
: children;
const displayText = isExpanded ? children : previewText;
const iconSize = getIconSize(variant);
if (!shouldTruncate) {
return (
<Text variant={variant} className={cn(className)}>
{children}
</Text>
);
}
return (
<Text
variant={variant}
className={cn(
isExpanded
? "flex-end flex flex-wrap items-center"
: "flex-start flex flex-wrap items-center",
className,
)}
>
{displayText}
{!isExpanded && "..."}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"ml-1 inline-flex items-center gap-1 font-medium text-black",
toggleClassName,
)}
type="button"
>
{isExpanded ? (
<>
<CaretUpIcon size={iconSize} weight="bold" />
<span>less</span>
</>
) : (
<>
<CaretDownIcon size={iconSize} weight="bold" />
<span>more</span>
</>
)}
</button>
</Text>
);
}

View File

@@ -0,0 +1,25 @@
import { TextVariant } from "@/components/atoms/Text/Text";
export type ShowMoreTextVariant = Exclude<
TextVariant,
"h1" | "h2" | "h3" | "h4"
>;
export function getIconSize(variant: ShowMoreTextVariant): number {
switch (variant) {
case "lead":
return 20;
case "large":
case "large-medium":
case "large-semibold":
return 16;
case "body":
case "body-medium":
return 14;
case "small":
case "small-medium":
return 12;
default:
return 14;
}
}

View File

@@ -0,0 +1,8 @@
declare module "*.svg" {
const content: {
src: string;
height: number;
width: number;
};
export default content;
}