Skip to content
18 changes: 9 additions & 9 deletions src/components/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ import { BadgeShape, ThemeVariant, getVariantColors } from "./utils";
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
$variant?: ThemeVariant;
$shape?: BadgeShape;
$isInteractive?: boolean;
$isClickable?: boolean;
}

/**
* Badge component for compact status/label pills.
* Displays colored badges with different variants (success, warning, error, info) and shapes.
* Displays colored badges with different variants (primary, success, warning, error, accent) and shapes.
* Supports interactive mode with hover effects for clickable badges.
* @param $variant - Visual variant using ThemeVariant enum.
* @param $shape - Shape of the badge: BadgeShape.Pill (fully rounded ends) or BadgeShape.Rounded (medium border radius). Defaults to Pill.
* @param $isInteractive - When true, adds hover/active states including translateY animation and shadow for clickable badges. Defaults to false.
* @param $isClickable - When true, adds hover/active states including translateY animation and shadow for clickable badges. Defaults to false.
* @example
* // Simple success badge
* <Badge $variant={ThemeVariant.Success}>Active</Badge>
* @example
* // Interactive warning badge with rounded shape
* <Badge $variant={ThemeVariant.Warning} $shape={BadgeShape.Rounded} $isInteractive>Click me</Badge>
* <Badge $variant={ThemeVariant.Warning} $shape={BadgeShape.Rounded} $isClickable>Click me</Badge>
*/
export const Badge = styled.span<BadgeProps>`
display: inline-flex;
Expand All @@ -31,19 +31,19 @@ export const Badge = styled.span<BadgeProps>`
border-radius: ${({ $shape = BadgeShape.Pill, theme }) =>
$shape === BadgeShape.Rounded ? theme.radius.md : theme.radius.full};
border: 1px solid
${({ $variant = ThemeVariant.Info, theme }) =>
${({ $variant = ThemeVariant.Primary, theme }) =>
getVariantColors(theme, $variant).border};

font: ${({ theme }) => theme.typography.small.font};
line-height: ${({ theme }) => theme.fonts.lineHeight.mini};

background: ${({ $variant = ThemeVariant.Info, theme }) =>
background: ${({ $variant = ThemeVariant.Primary, theme }) =>
getVariantColors(theme, $variant).bg};
color: ${({ $variant = ThemeVariant.Info, theme }) =>
color: ${({ $variant = ThemeVariant.Primary, theme }) =>
getVariantColors(theme, $variant).text};

${({ $isInteractive, theme }) =>
$isInteractive
${({ $isClickable, theme }) =>
$isClickable
? `
cursor: pointer;
transition: transform ${theme.transitions.base},
Expand Down
87 changes: 36 additions & 51 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Children } from "react";
import styled from "styled-components";
import { ControlLabel } from "./Typography";
import {
ButtonWidth,
getThemeSizeValuesByThemeSize,
getControlSize,
getVariantColors,
ThemeSize,
ThemeVariant,
Expand All @@ -13,85 +10,72 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
$variant?: ThemeVariant;
$ghost?: boolean;
$size?: ThemeSize;
$width?: ButtonWidth;
$fullWidth?: boolean;
}

/**
* Button component with multiple variants, sizes, ghost mode (transparent background), and width options.
* Supports all standard button HTML attributes and provides theme-based styling with smooth transitions.
* Includes disabled state handling with reduced opacity and cursor changes.
* @param $variant - Color variant: "primary" (main brand color), "secondary" (gray), "success" (green), "warning" (yellow), "error" (red), "info" (blue), "accent" (purple). Defaults to "info".
* @param $variant - Color variant: "primary" (blue), "success" (green), "warning" (yellow), "error" (red), "accent" (purple). Defaults to "primary".
* @param $ghost - When true, renders with transparent background and colored border/text. Hover fills with solid color. Defaults to false.
* @param $size - Size preset: "sm" (small padding), "md" (medium padding), "lg" (large padding). Affects padding and maintains consistent typography. Defaults to "md".
* @param $width - Width mode: "full" (100% width, useful in forms), "auto" (content-based width). Defaults to "auto".
* @param $size - Size preset, shared with Input and Select: "sm" (28px, 14px text), "md" (34px), "lg" (42px). Defaults to "md".
* @param $fullWidth - When true the button takes the full width of its container, useful in forms. Defaults to false.
* @example
* // Primary full-width button
* <Button onClick={handleSubmit}>Submit</Button>
* <Button $fullWidth onClick={handleSubmit}>Submit</Button>
* @example
* // Ghost error button with auto width
* <Button $variant="error" $ghost $width="auto" disabled>Delete</Button>
* // Ghost error button
* <Button $variant="error" $ghost disabled>Delete</Button>
* @example
* // Large success button
* <Button $variant="success" $size={ThemeSize.Large}>Complete Task</Button>
*/
export const Button = styled.button.attrs<ButtonProps>(({ children }) => ({
children: Children.map(children, (child) =>
typeof child === "string" || typeof child === "number"
? <ControlLabel>{child}</ControlLabel>
: child,
),
}))`
export const Button = styled.button<ButtonProps>`
display: inline-flex;
align-items: center;
justify-content: center;
gap: ${({ $size = ThemeSize.Medium }) =>
getThemeSizeValuesByThemeSize($size)};

${({ $width }) => ($width === ButtonWidth.Full ? "width: 100%;" : "")}
${({ $fullWidth }) => ($fullWidth ? "width: 100%;" : "")}

${({ $size = ThemeSize.Medium, theme }) => {
switch ($size) {
case ThemeSize.Small:
return `
padding: ${theme.spacing.xs} ${theme.spacing.sm};
`;
case ThemeSize.Medium:
return `
padding: ${theme.spacing.sm} ${theme.spacing.md};
`;
case ThemeSize.Large:
default:
return `
padding: ${theme.spacing.md} ${theme.spacing.lg};
`;
}
const { height, paddingX, gap } = getControlSize(theme, $size);
return `
gap: ${gap};
height: ${height};
padding: 0 ${paddingX};
`;
}}

border-radius: ${({ theme }) => theme.radius.md};

${({ $variant = ThemeVariant.Info, $ghost, theme }) => {
${({ $variant = ThemeVariant.Primary, $ghost, theme }) => {
const colors = getVariantColors(theme, $variant);

const darken = (amount: string) =>
`color-mix(in srgb, ${colors.solidBg}, ${theme.colors.shade} ${amount})`;
const hoverBg = $ghost
? colors.solidBg
: darken(theme.interactions.hoverDarken);
const activeBg = darken(theme.interactions.activeDarken);
const disabledBorder = $ghost
? theme.colors.gray[500]
: theme.colors.gray[300];

// A filled button always has a border in the same color as its fill.
return `
border: 1px solid ${colors.border};
border: 1px solid ${$ghost ? colors.border : colors.solidBg};
background-color: ${$ghost ? "transparent" : colors.solidBg};

color: ${$ghost ? colors.border : colors.solidText};

&:hover:not(:disabled) {
background-color: color-mix(
in srgb,
${colors.solidBg},
${theme.colors.shade} ${theme.interactions.hoverDarken}
);
border-color: ${hoverBg};
background-color: ${hoverBg};
}

&:active:not(:disabled) {
background-color: color-mix(
in srgb,
${colors.solidBg},
${theme.colors.shade} ${theme.interactions.activeDarken}
);
border-color: ${activeBg};
background-color: ${activeBg};
}

${
Expand All @@ -105,7 +89,7 @@ export const Button = styled.button.attrs<ButtonProps>(({ children }) => ({
}

&:disabled {
border-color: ${theme.colors.gray[500]};
border-color: ${disabledBorder};
background-color: ${$ghost ? "transparent" : theme.colors.gray[300]};
opacity: ${theme.interactions.disabledOpacity};

Expand All @@ -117,8 +101,9 @@ export const Button = styled.button.attrs<ButtonProps>(({ children }) => ({
}}

font: ${({ theme }) => theme.typography.bodyMedium.font};
font-size: ${({ $size = ThemeSize.Medium, theme }) =>
getControlSize(theme, $size).fontSize};

cursor: pointer;
transition:
background-color ${({ theme }) => theme.transitions.fast},
border-color ${({ theme }) => theme.transitions.fast},
Expand Down
21 changes: 10 additions & 11 deletions src/components/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const EditorTextarea = styled.textarea<{
$minHeight?: string;
}>`
width: 100%;
box-sizing: border-box;
min-height: ${({ $minHeight = "400px" }) => $minHeight};

padding: ${({ theme }) => theme.spacing.md};
Expand All @@ -24,7 +23,7 @@ const EditorTextarea = styled.textarea<{

background-color: ${({ theme }) => theme.colors.background.secondary};
color: ${({ theme }) => theme.colors.text.primary};
font: ${({ theme }) => theme.typography.code_small.font};
font: ${({ theme }) => theme.typography.code.font};

resize: vertical;

Expand All @@ -44,8 +43,8 @@ const EditorTextarea = styled.textarea<{
export interface CodeEditorProps extends React.HTMLAttributes<HTMLDivElement> {
$value: string;
$onChange: (value: string) => void;
$placeholder?: string;
$disabled?: boolean;
placeholder?: string;
disabled?: boolean;
$hasError?: boolean;
$minHeight?: string;
}
Expand All @@ -55,22 +54,22 @@ export interface CodeEditorProps extends React.HTMLAttributes<HTMLDivElement> {
* It is a controlled component and relies on `$value` / `$onChange` for state.
* @param $value - Current text content of the editor.
* @param $onChange - Callback invoked with new text when the content changes.
* @param $placeholder - Optional placeholder text when editor is empty.
* @param $disabled - When true the editor is non-editable and shows disabled visuals.
* @param placeholder - Optional placeholder text when editor is empty.
* @param disabled - When true the editor is non-editable and shows disabled visuals.
* @param $hasError - When true the editor displays an error border state.
* @param $minHeight - Optional CSS min-height for the editor area (defaults to `400px`).
* @example:
* <CodeEditor
* $value={source}
* $onChange={setSource}
* $placeholder="Paste JSON here"
* placeholder="Paste JSON here"
* />
*/
export const CodeEditor: React.FC<CodeEditorProps> = ({
$value,
$onChange,
$placeholder,
$disabled,
placeholder,
disabled,
$hasError,
$minHeight,
...rest
Expand All @@ -80,8 +79,8 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
<EditorTextarea
value={$value}
onChange={(e) => $onChange(e.target.value)}
placeholder={$placeholder}
disabled={$disabled}
placeholder={placeholder}
disabled={disabled}
$hasError={$hasError}
$minHeight={$minHeight}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
{...rest}
>
<Modal.Header>
{$title}
<Modal.Title>{$title}</Modal.Title>
<Modal.CloseButton onClick={$onCancel} disabled={$isPending} />
</Modal.Header>
<Modal.Body>{$description}</Modal.Body>
Expand Down
2 changes: 0 additions & 2 deletions src/components/Containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ export const PageContainer = styled.div<PageContainerProps>`
background-position: center;
background-repeat: no-repeat;
`}

padding: 0;
`;

export const CenteredCardContent = styled.div`
Expand Down
14 changes: 6 additions & 8 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,30 @@ import toast from "react-hot-toast";
import { formatErrorForToast } from "@/api/utils";
import { Button } from "./Button";
import Icon from "./Icon";
import { ThemeSize } from "./utils";

export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
$text: string;
$value: string;
}

/**
* Small icon-only button to copy text to clipboard.
* @param $text - The text to copy to clipboard.
* @param $value - The text to copy to the clipboard.
* @param title - The tooltip/title for the button (default: "Copy").
* @param disabled - Whether the button is disabled (default: false).
* @example
* <CopyButton $text="Some text to copy" />
* <CopyButton $value="Some text to copy" />
*/
export const CopyButton: React.FC<CopyButtonProps> = ({
$text,
$value,
disabled = false,
...rest
}) => {
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (disabled || !$text) return;
if (disabled || !$value) return;

try {
await navigator.clipboard.writeText($text);
await navigator.clipboard.writeText($value);
toast.success("Copied to clipboard");
} catch (err) {
toast.error(formatErrorForToast(err, "Failed to copy to clipboard"));
Expand All @@ -40,7 +39,6 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
<Button
type="button"
$ghost
$size={ThemeSize.Small}
onClick={handleClick}
disabled={disabled}
{...rest}
Expand Down
18 changes: 6 additions & 12 deletions src/components/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import React from "react";
import { Button } from "./Button";
import { ButtonWidth } from "./utils";
import { downloadExport, ExportFormat } from "./export";

export type { ExportFormat } from "./export";

export interface ExportButtonProps extends React.ComponentProps<typeof Button> {
$data: unknown;
$format: ExportFormat;
$filename?: string;
$fileName?: string;
$onSuccess?: () => void;
$onError?: (err: unknown) => void;
}
Expand All @@ -17,38 +16,33 @@ export interface ExportButtonProps extends React.ComponentProps<typeof Button> {
* ExportButton component to export data as a file in various formats.
*
* @param $data - The value to export. Can be any serializable value for json or a string for txt.
* @param $filename - Optional suggested filename. If not provided, defaults to export-<timestamp>.<ext>.
* @param $fileName - Optional suggested filename. If not provided, defaults to export-<timestamp>.<ext>.
* @param $format - **(required)** Selects how to transform data into a Blob. Can be 'json', 'txt', or a custom function returning { blob, filename? }.
* @param $onSuccess - Optional callback invoked after successful export.
* @param $onError - Optional callback invoked if export fails.
* @example
* <ExportButton $data={playbook} $filename={`${playbook.id}.json`} $format="json">Export</ExportButton>
* <ExportButton $data={playbook} $fileName={`${playbook.id}.json`} $format="json">Export</ExportButton>
*/
export const ExportButton: React.FC<ExportButtonProps> = ({
$data,
$format,
$filename,
$fileName,
$onSuccess,
$onError,
children,
...buttonProps
}) => {
const handleClick = () => {
try {
downloadExport($data, $format, $filename);
downloadExport($data, $format, $fileName);
if ($onSuccess) $onSuccess();
} catch (err) {
if ($onError) $onError(err);
}
};

return (
<Button
type="button"
onClick={handleClick}
$width={buttonProps.$width ?? ButtonWidth.Full}
{...buttonProps}
>
<Button type="button" onClick={handleClick} {...buttonProps}>
{children}
</Button>
);
Expand Down
Loading
Loading