feat: github fix and ui improvements (#1685)

* feat: Add reusable UI components and fix GitHub repository display

* style: Fix linting issues in UI components

* fix: Add close icon to GitHub Connection Required dialog

* fix: Add CloseButton component to fix white background issue in dialog close icons

* Fix close button styling in dialog components to address ghost white issue in dark mode

* fix: update icon color to tertiary for consistency

The icon color was changed from `text-bolt-elements-icon-info` to `text-bolt-elements-icon-tertiary`

* fix: improve repository selection dialog tab styling for dark mode

- Update tab menu styling to prevent white background in dark mode
- Use explicit color values for better dark/light mode compatibility
- Improve hover and active states for better visual hierarchy
- Remove unused Tabs imports

---------

Co-authored-by: KevIsDev <zennerd404@gmail.com>
This commit is contained in:
Stijnus
2025-05-09 15:23:20 +02:00
committed by GitHub
parent 9a5076d8c6
commit 870bfc58ee
28 changed files with 2868 additions and 689 deletions

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { classNames } from '~/utils/classNames';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
'inline-flex items-center gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-bolt-elements-ring focus:ring-offset-2',
{
variants: {
variant: {
@@ -15,18 +15,39 @@ const badgeVariants = cva(
'border-transparent bg-bolt-elements-background text-bolt-elements-textSecondary hover:bg-bolt-elements-background/80',
destructive: 'border-transparent bg-red-500/10 text-red-500 hover:bg-red-500/20',
outline: 'text-bolt-elements-textPrimary',
primary: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
success: 'bg-green-500/10 text-green-600 dark:text-green-400',
warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400',
danger: 'bg-red-500/10 text-red-600 dark:text-red-400',
info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
subtle:
'border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30 bg-white/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
},
size: {
default: 'rounded-full px-2.5 py-0.5 text-xs font-semibold',
sm: 'rounded-full px-1.5 py-0.5 text-xs',
md: 'rounded-md px-2 py-1 text-xs font-medium',
lg: 'rounded-md px-2.5 py-1.5 text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
icon?: string;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={classNames(badgeVariants({ variant }), className)} {...props} />;
function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) {
return (
<div className={classNames(badgeVariants({ variant, size }), className)} {...props}>
{icon && <span className={icon} />}
{children}
</div>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
interface BreadcrumbItem {
label: string;
href?: string;
icon?: string;
onClick?: () => void;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
className?: string;
separator?: string;
maxItems?: number;
renderItem?: (item: BreadcrumbItem, index: number, isLast: boolean) => React.ReactNode;
}
export function Breadcrumbs({
items,
className,
separator = 'i-ph:caret-right',
maxItems = 0,
renderItem,
}: BreadcrumbsProps) {
const displayItems =
maxItems > 0 && items.length > maxItems
? [
...items.slice(0, 1),
{ label: '...', onClick: undefined, href: undefined },
...items.slice(-Math.max(1, maxItems - 2)),
]
: items;
const defaultRenderItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
const content = (
<div className="flex items-center gap-1.5">
{item.icon && <span className={classNames(item.icon, 'w-3.5 h-3.5')} />}
<span
className={classNames(
isLast
? 'font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
: 'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary-dark',
item.onClick || item.href ? 'cursor-pointer' : '',
)}
>
{item.label}
</span>
</div>
);
if (item.href && !isLast) {
return (
<motion.a href={item.href} className="hover:underline" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
{content}
</motion.a>
);
}
if (item.onClick && !isLast) {
return (
<motion.button
type="button"
onClick={item.onClick}
className="hover:underline"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{content}
</motion.button>
);
}
return content;
};
return (
<nav className={classNames('flex items-center', className)} aria-label="Breadcrumbs">
<ol className="flex items-center gap-1.5">
{displayItems.map((item, index) => {
const isLast = index === displayItems.length - 1;
return (
<li key={index} className="flex items-center">
{renderItem ? renderItem(item, index, isLast) : defaultRenderItem(item, index, isLast)}
{!isLast && (
<span
className={classNames(
separator,
'w-3 h-3 mx-1 text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
)}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
interface CloseButtonProps {
onClick?: () => void;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
/**
* CloseButton component
*
* A button with an X icon used for closing dialogs, modals, etc.
* The button has a transparent background and only shows a background on hover.
*/
export function CloseButton({ onClick, className, size = 'md' }: CloseButtonProps) {
const sizeClasses = {
sm: 'p-1',
md: 'p-2',
lg: 'p-3',
};
const iconSizeClasses = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
return (
<motion.button
type="button"
onClick={onClick}
className={classNames(
'text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textSecondary-dark',
'rounded-lg hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
'transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
sizeClasses[size],
className,
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="Close"
>
<div className={classNames('i-ph:x', iconSizeClasses[size])} />
</motion.button>
);
}

View File

@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
import { FileIcon } from './FileIcon';
import { Tooltip } from './Tooltip';
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
showLineNumbers?: boolean;
highlightLines?: number[];
maxHeight?: string;
className?: string;
onCopy?: () => void;
}
export function CodeBlock({
code,
language,
filename,
showLineNumbers = true,
highlightLines = [],
maxHeight = '400px',
className,
onCopy,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
onCopy?.();
};
const lines = code.split('\n');
return (
<div
className={classNames(
'rounded-lg overflow-hidden border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3',
className,
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 border-b border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<div className="flex items-center gap-2">
{filename && (
<>
<FileIcon filename={filename} size="sm" />
<span className="text-xs font-medium text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
{filename}
</span>
</>
)}
{language && !filename && (
<span className="text-xs font-medium text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark uppercase">
{language}
</span>
)}
</div>
<Tooltip content={copied ? 'Copied!' : 'Copy code'}>
<motion.button
onClick={handleCopy}
className="p-1.5 rounded-md text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3 transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{copied ? <span className="i-ph:check w-4 h-4 text-green-500" /> : <span className="i-ph:copy w-4 h-4" />}
</motion.button>
</Tooltip>
</div>
{/* Code content */}
<div className={classNames('overflow-auto', 'font-mono text-sm', 'custom-scrollbar')} style={{ maxHeight }}>
<table className="min-w-full border-collapse">
<tbody>
{lines.map((line, index) => (
<tr
key={index}
className={classNames(
highlightLines.includes(index + 1) ? 'bg-purple-500/10 dark:bg-purple-500/20' : '',
'hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4',
)}
>
{showLineNumbers && (
<td className="py-1 pl-4 pr-2 text-right select-none text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark border-r border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark">
<span className="inline-block min-w-[1.5rem] text-xs">{index + 1}</span>
</td>
)}
<td className="py-1 pl-4 pr-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark whitespace-pre">
{line || ' '}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
import { Button } from './Button';
import { motion } from 'framer-motion';
// Variant-specific styles
const VARIANT_STYLES = {
default: {
container: 'py-8 p-6',
icon: {
container: 'w-12 h-12 mb-3',
size: 'w-6 h-6',
},
title: 'text-base',
description: 'text-sm mt-1',
actions: 'mt-4',
buttonSize: 'default' as const,
},
compact: {
container: 'py-4 p-4',
icon: {
container: 'w-10 h-10 mb-2',
size: 'w-5 h-5',
},
title: 'text-sm',
description: 'text-xs mt-0.5',
actions: 'mt-3',
buttonSize: 'sm' as const,
},
};
interface EmptyStateProps {
/** Icon class name */
icon?: string;
/** Title text */
title: string;
/** Optional description text */
description?: string;
/** Primary action button label */
actionLabel?: string;
/** Primary action button callback */
onAction?: () => void;
/** Secondary action button label */
secondaryActionLabel?: string;
/** Secondary action button callback */
onSecondaryAction?: () => void;
/** Additional class name */
className?: string;
/** Component size variant */
variant?: 'default' | 'compact';
}
/**
* EmptyState component
*
* A component for displaying empty states with optional actions.
*/
export function EmptyState({
icon = 'i-ph:folder-simple-dashed',
title,
description,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
className,
variant = 'default',
}: EmptyStateProps) {
// Get styles based on variant
const styles = VARIANT_STYLES[variant];
// Animation variants for buttons
const buttonAnimation = {
whileHover: { scale: 1.02 },
whileTap: { scale: 0.98 },
};
return (
<div
className={classNames(
'flex flex-col items-center justify-center',
'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg',
styles.container,
className,
)}
>
{/* Icon */}
<div
className={classNames(
'rounded-full bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 flex items-center justify-center',
styles.icon.container,
)}
>
<span
className={classNames(
icon,
styles.icon.size,
'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
)}
/>
</div>
{/* Title */}
<p className={classNames('font-medium', styles.title)}>{title}</p>
{/* Description */}
{description && (
<p
className={classNames(
'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark text-center max-w-xs',
styles.description,
)}
>
{description}
</p>
)}
{/* Action buttons */}
{(actionLabel || secondaryActionLabel) && (
<div className={classNames('flex items-center gap-2', styles.actions)}>
{actionLabel && onAction && (
<motion.div {...buttonAnimation}>
<Button
onClick={onAction}
variant="default"
size={styles.buttonSize}
className="bg-purple-500 hover:bg-purple-600 text-white"
>
{actionLabel}
</Button>
</motion.div>
)}
{secondaryActionLabel && onSecondaryAction && (
<motion.div {...buttonAnimation}>
<Button onClick={onSecondaryAction} variant="outline" size={styles.buttonSize}>
{secondaryActionLabel}
</Button>
</motion.div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,346 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
interface FileIconProps {
filename: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function FileIcon({ filename, size = 'md', className }: FileIconProps) {
const getFileExtension = (filename: string): string => {
return filename.split('.').pop()?.toLowerCase() || '';
};
const getIconForExtension = (extension: string): string => {
// Code files
if (['js', 'jsx', 'ts', 'tsx'].includes(extension)) {
return 'i-ph:file-js';
}
if (['html', 'htm', 'xhtml'].includes(extension)) {
return 'i-ph:file-html';
}
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
return 'i-ph:file-css';
}
if (['json', 'jsonc'].includes(extension)) {
return 'i-ph:brackets-curly';
}
if (['md', 'markdown'].includes(extension)) {
return 'i-ph:file-text';
}
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
return 'i-ph:file-py';
}
if (['java', 'class', 'jar'].includes(extension)) {
return 'i-ph:file-java';
}
if (['php'].includes(extension)) {
return 'i-ph:file-php';
}
if (['rb', 'ruby'].includes(extension)) {
return 'i-ph:file-rs';
}
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
return 'i-ph:file-cpp';
}
if (['go'].includes(extension)) {
return 'i-ph:file-rs';
}
if (['rs', 'rust'].includes(extension)) {
return 'i-ph:file-rs';
}
if (['swift'].includes(extension)) {
return 'i-ph:file-swift';
}
if (['kt', 'kotlin'].includes(extension)) {
return 'i-ph:file-kotlin';
}
if (['dart'].includes(extension)) {
return 'i-ph:file-dart';
}
// Config files
if (['yml', 'yaml'].includes(extension)) {
return 'i-ph:file-cloud';
}
if (['xml', 'svg'].includes(extension)) {
return 'i-ph:file-xml';
}
if (['toml'].includes(extension)) {
return 'i-ph:file-text';
}
if (['ini', 'conf', 'config'].includes(extension)) {
return 'i-ph:file-text';
}
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
return 'i-ph:file-lock';
}
// Document files
if (['pdf'].includes(extension)) {
return 'i-ph:file-pdf';
}
if (['doc', 'docx'].includes(extension)) {
return 'i-ph:file-doc';
}
if (['xls', 'xlsx'].includes(extension)) {
return 'i-ph:file-xls';
}
if (['ppt', 'pptx'].includes(extension)) {
return 'i-ph:file-ppt';
}
if (['txt'].includes(extension)) {
return 'i-ph:file-text';
}
// Image files
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
return 'i-ph:file-image';
}
// Audio/Video files
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
return 'i-ph:file-audio';
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
return 'i-ph:file-video';
}
// Archive files
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
return 'i-ph:file-zip';
}
// Special files
if (filename === 'package.json') {
return 'i-ph:package';
}
if (filename === 'tsconfig.json') {
return 'i-ph:file-ts';
}
if (filename === 'README.md') {
return 'i-ph:book-open';
}
if (filename === 'LICENSE') {
return 'i-ph:scales';
}
if (filename === '.gitignore') {
return 'i-ph:git-branch';
}
if (filename.startsWith('Dockerfile')) {
return 'i-ph:docker-logo';
}
// Default
return 'i-ph:file';
};
const getIconColorForExtension = (extension: string): string => {
// Code files
if (['js', 'jsx'].includes(extension)) {
return 'text-yellow-500';
}
if (['ts', 'tsx'].includes(extension)) {
return 'text-blue-500';
}
if (['html', 'htm', 'xhtml'].includes(extension)) {
return 'text-orange-500';
}
if (['css', 'scss', 'sass', 'less'].includes(extension)) {
return 'text-blue-400';
}
if (['json', 'jsonc'].includes(extension)) {
return 'text-yellow-400';
}
if (['md', 'markdown'].includes(extension)) {
return 'text-gray-500';
}
if (['py', 'pyc', 'pyd', 'pyo'].includes(extension)) {
return 'text-green-500';
}
if (['java', 'class', 'jar'].includes(extension)) {
return 'text-red-500';
}
if (['php'].includes(extension)) {
return 'text-purple-500';
}
if (['rb', 'ruby'].includes(extension)) {
return 'text-red-600';
}
if (['c', 'cpp', 'h', 'hpp', 'cc'].includes(extension)) {
return 'text-blue-600';
}
if (['go'].includes(extension)) {
return 'text-cyan-500';
}
if (['rs', 'rust'].includes(extension)) {
return 'text-orange-600';
}
if (['swift'].includes(extension)) {
return 'text-orange-500';
}
if (['kt', 'kotlin'].includes(extension)) {
return 'text-purple-400';
}
if (['dart'].includes(extension)) {
return 'text-cyan-400';
}
// Config files
if (['yml', 'yaml'].includes(extension)) {
return 'text-purple-300';
}
if (['xml'].includes(extension)) {
return 'text-orange-300';
}
if (['svg'].includes(extension)) {
return 'text-green-400';
}
if (['toml'].includes(extension)) {
return 'text-gray-500';
}
if (['ini', 'conf', 'config'].includes(extension)) {
return 'text-gray-500';
}
if (['env', 'env.local', 'env.development', 'env.production'].includes(extension)) {
return 'text-green-500';
}
// Document files
if (['pdf'].includes(extension)) {
return 'text-red-500';
}
if (['doc', 'docx'].includes(extension)) {
return 'text-blue-600';
}
if (['xls', 'xlsx'].includes(extension)) {
return 'text-green-600';
}
if (['ppt', 'pptx'].includes(extension)) {
return 'text-red-600';
}
if (['txt'].includes(extension)) {
return 'text-gray-500';
}
// Image files
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(extension)) {
return 'text-pink-500';
}
// Audio/Video files
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(extension)) {
return 'text-green-500';
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) {
return 'text-blue-500';
}
// Archive files
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
return 'text-yellow-600';
}
// Special files
if (filename === 'package.json') {
return 'text-red-400';
}
if (filename === 'tsconfig.json') {
return 'text-blue-500';
}
if (filename === 'README.md') {
return 'text-blue-400';
}
if (filename === 'LICENSE') {
return 'text-gray-500';
}
if (filename === '.gitignore') {
return 'text-orange-500';
}
if (filename.startsWith('Dockerfile')) {
return 'text-blue-500';
}
// Default
return 'text-gray-400';
};
const getSizeClass = (size: 'sm' | 'md' | 'lg'): string => {
switch (size) {
case 'sm':
return 'w-4 h-4';
case 'md':
return 'w-5 h-5';
case 'lg':
return 'w-6 h-6';
default:
return 'w-5 h-5';
}
};
const extension = getFileExtension(filename);
const icon = getIconForExtension(extension);
const color = getIconColorForExtension(extension);
const sizeClass = getSizeClass(size);
return <span className={classNames(icon, color, sizeClass, className)} />;
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
interface FilterChipProps {
/** The label text to display */
label: string;
/** Optional value to display after the label */
value?: string | number;
/** Function to call when the remove button is clicked */
onRemove?: () => void;
/** Whether the chip is active/selected */
active?: boolean;
/** Optional icon to display before the label */
icon?: string;
/** Additional class name */
className?: string;
}
/**
* FilterChip component
*
* A chip component for displaying filters with optional remove button.
*/
export function FilterChip({ label, value, onRemove, active = false, icon, className }: FilterChipProps) {
// Animation variants
const variants = {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
};
return (
<motion.div
initial="initial"
animate="animate"
exit="exit"
variants={variants}
transition={{ duration: 0.2 }}
className={classNames(
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
active
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border border-purple-500/30'
: 'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
onRemove && 'pr-1',
className,
)}
>
{/* Icon */}
{icon && <span className={classNames(icon, 'text-inherit')} />}
{/* Label and value */}
<span>
{label}
{value !== undefined && ': '}
{value !== undefined && (
<span
className={
active
? 'text-purple-700 dark:text-purple-300 font-semibold'
: 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark'
}
>
{value}
</span>
)}
</span>
{/* Remove button */}
{onRemove && (
<button
type="button"
onClick={onRemove}
className={classNames(
'ml-1 p-0.5 rounded-full hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors',
active
? 'text-purple-600 dark:text-purple-400'
: 'text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark',
)}
aria-label={`Remove ${label} filter`}
>
<span className="i-ph:x w-3 h-3" />
</button>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
// Predefined gradient colors
const GRADIENT_COLORS = [
'from-purple-500/10 to-blue-500/5',
'from-blue-500/10 to-cyan-500/5',
'from-cyan-500/10 to-green-500/5',
'from-green-500/10 to-yellow-500/5',
'from-yellow-500/10 to-orange-500/5',
'from-orange-500/10 to-red-500/5',
'from-red-500/10 to-pink-500/5',
'from-pink-500/10 to-purple-500/5',
];
interface GradientCardProps {
/** Custom gradient class (overrides seed-based gradient) */
gradient?: string;
/** Seed string to determine gradient color */
seed?: string;
/** Whether to apply hover animation effect */
hoverEffect?: boolean;
/** Whether to apply border effect */
borderEffect?: boolean;
/** Card content */
children: React.ReactNode;
/** Additional class name */
className?: string;
/** Additional props */
[key: string]: any;
}
/**
* GradientCard component
*
* A card with a gradient background that can be determined by a seed string.
*/
export function GradientCard({
gradient,
seed,
hoverEffect = true,
borderEffect = true,
className,
children,
...props
}: GradientCardProps) {
// Get gradient color based on seed or use provided gradient
const gradientClass = gradient || getGradientColorFromSeed(seed);
// Animation variants for hover effect
const hoverAnimation = hoverEffect
? {
whileHover: {
scale: 1.02,
y: -2,
transition: { type: 'spring', stiffness: 400, damping: 17 },
},
whileTap: { scale: 0.98 },
}
: undefined;
return (
<motion.div
className={classNames(
'p-5 rounded-xl bg-gradient-to-br',
gradientClass,
borderEffect
? 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40'
: '',
'transition-all duration-300 shadow-sm',
hoverEffect ? 'hover:shadow-md' : '',
className,
)}
{...hoverAnimation}
{...props}
>
{children}
</motion.div>
);
}
/**
* Calculate a gradient color based on the seed string for visual variety
*/
function getGradientColorFromSeed(seedString?: string): string {
if (!seedString) {
return GRADIENT_COLORS[0];
}
const index = seedString.length % GRADIENT_COLORS.length;
return GRADIENT_COLORS[index];
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Badge } from './Badge';
import { classNames } from '~/utils/classNames';
import { formatSize } from '~/utils/formatSize';
interface RepositoryStatsProps {
stats: {
totalFiles?: number;
totalSize?: number;
languages?: Record<string, number>;
hasPackageJson?: boolean;
hasDependencies?: boolean;
};
className?: string;
compact?: boolean;
}
export function RepositoryStats({ stats, className, compact = false }: RepositoryStatsProps) {
const { totalFiles, totalSize, languages, hasPackageJson, hasDependencies } = stats;
return (
<div className={classNames('space-y-3', className)}>
{!compact && (
<p className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Repository Statistics:
</p>
)}
<div className={classNames('grid gap-3', compact ? 'grid-cols-2' : 'grid-cols-2 md:grid-cols-3')}>
{totalFiles !== undefined && (
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
<span className="i-ph:files text-purple-500 w-4 h-4" />
<span className={compact ? 'text-xs' : 'text-sm'}>Total Files: {totalFiles.toLocaleString()}</span>
</div>
)}
{totalSize !== undefined && (
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
<span className="i-ph:database text-purple-500 w-4 h-4" />
<span className={compact ? 'text-xs' : 'text-sm'}>Total Size: {formatSize(totalSize)}</span>
</div>
)}
</div>
{languages && Object.keys(languages).length > 0 && (
<div className={compact ? 'pt-1' : 'pt-2'}>
<div className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark mb-2">
<span className="i-ph:code text-purple-500 w-4 h-4" />
<span className={compact ? 'text-xs' : 'text-sm'}>Languages:</span>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(languages)
.sort(([, a], [, b]) => b - a)
.slice(0, compact ? 3 : 5)
.map(([lang, size]) => (
<Badge key={lang} variant="subtle" size={compact ? 'sm' : 'md'}>
{lang} ({formatSize(size)})
</Badge>
))}
{Object.keys(languages).length > (compact ? 3 : 5) && (
<Badge variant="subtle" size={compact ? 'sm' : 'md'}>
+{Object.keys(languages).length - (compact ? 3 : 5)} more
</Badge>
)}
</div>
</div>
)}
{(hasPackageJson || hasDependencies) && (
<div className={compact ? 'pt-1' : 'pt-2'}>
<div className="flex flex-wrap gap-2">
{hasPackageJson && (
<Badge variant="primary" size={compact ? 'sm' : 'md'} icon="i-ph:package w-3.5 h-3.5">
package.json
</Badge>
)}
{hasDependencies && (
<Badge variant="primary" size={compact ? 'sm' : 'md'} icon="i-ph:tree-structure w-3.5 h-3.5">
Dependencies
</Badge>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import React, { forwardRef } from 'react';
import { classNames } from '~/utils/classNames';
import { Input } from './Input';
import { motion, AnimatePresence } from 'framer-motion';
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** Function to call when the clear button is clicked */
onClear?: () => void;
/** Whether to show the clear button when there is input */
showClearButton?: boolean;
/** Additional class name for the search icon */
iconClassName?: string;
/** Additional class name for the container */
containerClassName?: string;
/** Whether the search is loading */
loading?: boolean;
}
/**
* SearchInput component
*
* A search input field with a search icon and optional clear button.
*/
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
(
{ className, onClear, showClearButton = true, iconClassName, containerClassName, loading = false, ...props },
ref,
) => {
const hasValue = Boolean(props.value);
return (
<div className={classNames('relative flex items-center w-full', containerClassName)}>
{/* Search icon or loading spinner */}
<div
className={classNames(
'absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary',
iconClassName,
)}
>
{loading ? (
<span className="i-ph:spinner-gap animate-spin w-4 h-4" />
) : (
<span className="i-ph:magnifying-glass w-4 h-4" />
)}
</div>
{/* Input field */}
<Input
ref={ref}
className={classNames('pl-10', hasValue && showClearButton ? 'pr-10' : '', className)}
{...props}
/>
{/* Clear button */}
<AnimatePresence>
{hasValue && showClearButton && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary hover:text-bolt-elements-textSecondary p-1 rounded-full hover:bg-bolt-elements-background-depth-2"
aria-label="Clear search"
>
<span className="i-ph:x w-3.5 h-3.5" />
</motion.button>
)}
</AnimatePresence>
</div>
);
},
);
SearchInput.displayName = 'SearchInput';

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
import { Badge } from './Badge';
interface SearchResultItemProps {
title: string;
subtitle?: string;
description?: string;
icon?: string;
iconBackground?: string;
iconColor?: string;
tags?: string[];
metadata?: Array<{
icon?: string;
label: string;
value?: string | number;
}>;
actionLabel?: string;
onAction?: () => void;
onClick?: () => void;
className?: string;
}
export function SearchResultItem({
title,
subtitle,
description,
icon,
iconBackground = 'bg-bolt-elements-background-depth-1/80 dark:bg-bolt-elements-background-depth-4/80',
iconColor = 'text-purple-500',
tags,
metadata,
actionLabel,
onAction,
onClick,
className,
}: SearchResultItemProps) {
return (
<motion.div
className={classNames(
'p-5 rounded-xl border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/40 transition-all duration-300 shadow-sm hover:shadow-md bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-3/50',
onClick ? 'cursor-pointer' : '',
className,
)}
whileHover={{
scale: 1.01,
y: -1,
transition: { type: 'spring', stiffness: 400, damping: 17 },
}}
whileTap={{ scale: 0.99 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
onClick={onClick}
>
<div className="flex items-start justify-between mb-3 gap-3">
<div className="flex items-start gap-3">
{icon && (
<div
className={classNames(
'w-10 h-10 rounded-xl backdrop-blur-sm flex items-center justify-center shadow-sm',
iconBackground,
)}
>
<span className={classNames(icon, 'w-5 h-5', iconColor)} />
</div>
)}
<div>
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark text-base">
{title}
</h3>
{subtitle && (
<p className="text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark flex items-center gap-1">
{subtitle}
</p>
)}
</div>
</div>
{actionLabel && onAction && (
<motion.button
onClick={(e) => {
e.stopPropagation();
onAction();
}}
className="px-4 py-2 h-9 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[100px] justify-center text-sm shadow-sm hover:shadow-md"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{actionLabel}
</motion.button>
)}
</div>
{description && (
<div className="mb-4 bg-bolt-elements-background-depth-1/50 dark:bg-bolt-elements-background-depth-4/50 backdrop-blur-sm p-3 rounded-lg border border-bolt-elements-borderColor/30 dark:border-bolt-elements-borderColor-dark/30">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark line-clamp-2">
{description}
</p>
</div>
)}
{tags && tags.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{tags.map((tag) => (
<Badge key={tag} variant="subtle" size="sm">
{tag}
</Badge>
))}
</div>
)}
{metadata && metadata.length > 0 && (
<div className="flex flex-wrap items-center gap-3 text-xs text-bolt-elements-textTertiary dark:text-bolt-elements-textTertiary-dark">
{metadata.map((item, index) => (
<div key={index} className="flex items-center gap-1">
{item.icon && <span className={classNames(item.icon, 'w-3.5 h-3.5')} />}
<span>
{item.label}
{item.value !== undefined && ': '}
{item.value !== undefined && (
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
{item.value}
</span>
)}
</span>
</div>
))}
</div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { classNames } from '~/utils/classNames';
// Status types supported by the component
type StatusType = 'online' | 'offline' | 'away' | 'busy' | 'success' | 'warning' | 'error' | 'info' | 'loading';
// Size types for the indicator
type SizeType = 'sm' | 'md' | 'lg';
// Status color mapping
const STATUS_COLORS: Record<StatusType, string> = {
online: 'bg-green-500',
success: 'bg-green-500',
offline: 'bg-red-500',
error: 'bg-red-500',
away: 'bg-yellow-500',
warning: 'bg-yellow-500',
busy: 'bg-red-500',
info: 'bg-blue-500',
loading: 'bg-purple-500',
};
// Size class mapping
const SIZE_CLASSES: Record<SizeType, string> = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4',
};
// Text size mapping based on indicator size
const TEXT_SIZE_CLASSES: Record<SizeType, string> = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
};
interface StatusIndicatorProps {
/** The status to display */
status: StatusType;
/** Size of the indicator */
size?: SizeType;
/** Whether to show a pulsing animation */
pulse?: boolean;
/** Optional label text */
label?: string;
/** Additional class name */
className?: string;
}
/**
* StatusIndicator component
*
* A component for displaying status indicators with optional labels and pulse animations.
*/
export function StatusIndicator({ status, size = 'md', pulse = false, label, className }: StatusIndicatorProps) {
// Get the color class for the status
const colorClass = STATUS_COLORS[status] || 'bg-gray-500';
// Get the size class for the indicator
const sizeClass = SIZE_CLASSES[size];
// Get the text size class for the label
const textSizeClass = TEXT_SIZE_CLASSES[size];
return (
<div className={classNames('flex items-center gap-2', className)}>
{/* Status indicator dot */}
<span className={classNames('rounded-full relative', colorClass, sizeClass)}>
{/* Pulse animation */}
{pulse && <span className={classNames('absolute inset-0 rounded-full animate-ping opacity-75', colorClass)} />}
</span>
{/* Optional label */}
{label && (
<span
className={classNames(
'text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark',
textSizeClass,
)}
>
{label}
</span>
)}
</div>
);
}

View File

@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={classNames(
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background p-1 text-bolt-elements-textSecondary',
'inline-flex h-10 items-center justify-center rounded-md bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-3-dark p-1 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
className,
)}
{...props}
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={classNames(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background data-[state=active]:text-bolt-elements-textPrimary data-[state=active]:shadow-sm',
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-bolt-elements-background dark:ring-offset-bolt-elements-background-dark transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bolt-elements-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-bolt-elements-background-depth-0 dark:data-[state=active]:bg-bolt-elements-background-depth-2-dark data-[state=active]:text-bolt-elements-textPrimary dark:data-[state=active]:text-bolt-elements-textPrimary-dark data-[state=active]:shadow-sm',
className,
)}
{...props}

View File

@@ -0,0 +1,112 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { classNames } from '~/utils/classNames';
interface Tab {
/** Unique identifier for the tab */
id: string;
/** Content to display in the tab */
label: React.ReactNode;
/** Optional icon to display before the label */
icon?: string;
}
interface TabsWithSliderProps {
/** Array of tab objects */
tabs: Tab[];
/** ID of the currently active tab */
activeTab: string;
/** Function called when a tab is clicked */
onChange: (tabId: string) => void;
/** Additional class name for the container */
className?: string;
/** Additional class name for inactive tabs */
tabClassName?: string;
/** Additional class name for the active tab */
activeTabClassName?: string;
/** Additional class name for the slider */
sliderClassName?: string;
}
/**
* TabsWithSlider component
*
* A tabs component with an animated slider that moves to the active tab.
*/
export function TabsWithSlider({
tabs,
activeTab,
onChange,
className,
tabClassName,
activeTabClassName,
sliderClassName,
}: TabsWithSliderProps) {
// State for slider dimensions
const [sliderDimensions, setSliderDimensions] = useState({ width: 0, left: 0 });
// Refs for tab elements
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
// Update slider position when active tab changes
useEffect(() => {
const activeIndex = tabs.findIndex((tab) => tab.id === activeTab);
if (activeIndex !== -1 && tabsRef.current[activeIndex]) {
const activeTabElement = tabsRef.current[activeIndex];
if (activeTabElement) {
setSliderDimensions({
width: activeTabElement.offsetWidth,
left: activeTabElement.offsetLeft,
});
}
}
}, [activeTab, tabs]);
return (
<div className={classNames('relative flex gap-2', className)}>
{/* Tab buttons */}
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => (tabsRef.current[index] = el)}
onClick={() => onChange(tab.id)}
className={classNames(
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center relative overflow-hidden',
tab.id === activeTab
? classNames('text-white shadow-sm shadow-purple-500/20', activeTabClassName)
: classNames(
'bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark',
tabClassName,
),
)}
>
<span className={classNames('flex items-center gap-2', tab.id === activeTab ? 'font-medium' : '')}>
{tab.icon && <span className={tab.icon} />}
{tab.label}
</span>
</button>
))}
{/* Animated slider */}
<motion.div
className={classNames('absolute bottom-0 left-0 h-10 rounded-lg bg-purple-500 -z-10', sliderClassName)}
initial={false}
animate={{
width: sliderDimensions.width,
x: sliderDimensions.left,
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { forwardRef, type ForwardedRef, type ReactElement } from 'react';
import { classNames } from '~/utils/classNames';
interface TooltipProps {
// Original WithTooltip component
interface WithTooltipProps {
tooltip: React.ReactNode;
children: ReactElement;
sideOffset?: number;
@@ -25,55 +27,96 @@ const WithTooltip = forwardRef(
position = 'top',
maxWidth = 250,
delay = 0,
}: TooltipProps,
}: WithTooltipProps,
_ref: ForwardedRef<HTMLElement>,
) => {
return (
<Tooltip.Root delayDuration={delay}>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={position}
className={`
z-[2000]
px-2.5
py-1.5
max-h-[300px]
select-none
rounded-md
bg-bolt-elements-background-depth-3
text-bolt-elements-textPrimary
text-sm
leading-tight
shadow-lg
animate-in
fade-in-0
zoom-in-95
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
data-[state=closed]:zoom-out-95
${className}
`}
sideOffset={sideOffset}
style={{
maxWidth,
...tooltipStyle,
}}
>
<div className="break-words">{tooltip}</div>
<Tooltip.Arrow
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root delayDuration={delay}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={position}
className={`
fill-bolt-elements-background-depth-3
${arrowClassName}
z-[2000]
px-2.5
py-1.5
max-h-[300px]
select-none
rounded-md
bg-bolt-elements-background-depth-3
text-bolt-elements-textPrimary
text-sm
leading-tight
shadow-lg
animate-in
fade-in-0
zoom-in-95
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
data-[state=closed]:zoom-out-95
${className}
`}
width={12}
height={6}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
sideOffset={sideOffset}
style={{
maxWidth,
...tooltipStyle,
}}
>
<div className="break-words">{tooltip}</div>
<TooltipPrimitive.Arrow
className={`
fill-bolt-elements-background-depth-3
${arrowClassName}
`}
width={12}
height={6}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
},
);
// New Tooltip component with simpler API
interface TooltipProps {
content: React.ReactNode;
children: React.ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
align?: 'start' | 'center' | 'end';
delayDuration?: number;
className?: string;
}
export function Tooltip({
content,
children,
side = 'top',
align = 'center',
delayDuration = 300,
className,
}: TooltipProps) {
return (
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root delayDuration={delayDuration}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content
side={side}
align={align}
className={classNames(
'z-50 overflow-hidden rounded-md bg-bolt-elements-background-depth-3 dark:bg-bolt-elements-background-depth-4 px-3 py-1.5 text-xs text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
sideOffset={5}
>
{content}
<TooltipPrimitive.Arrow className="fill-bolt-elements-background-depth-3 dark:fill-bolt-elements-background-depth-4" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}
export default WithTooltip;

View File

@@ -0,0 +1,38 @@
// Export all UI components for easier imports
// Core components
export * from './Badge';
export * from './Button';
export * from './Card';
export * from './Checkbox';
export * from './Collapsible';
export * from './Dialog';
export * from './IconButton';
export * from './Input';
export * from './Label';
export * from './ScrollArea';
export * from './Switch';
export * from './Tabs';
export * from './ThemeSwitch';
// Loading components
export * from './LoadingDots';
export * from './LoadingOverlay';
// New components
export * from './Breadcrumbs';
export * from './CloseButton';
export * from './CodeBlock';
export * from './EmptyState';
export * from './FileIcon';
export * from './FilterChip';
export * from './GradientCard';
export * from './RepositoryStats';
export * from './SearchInput';
export * from './SearchResultItem';
export * from './StatusIndicator';
export * from './TabsWithSlider';
// Tooltip components
export { default as WithTooltip } from './Tooltip';
export { Tooltip } from './Tooltip';