shadimage
Awesome Cursor Rules
uiattachmentsource: ok · refreshed

Attachment

Attachment primitives for Tile UI.

npx shadcn add "https://react.tileui.zmorg.cn/r/attachment.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@radix-ui/react-slot

Registry dependencies

@tile-ui/corebutton@tile-ui/styles

Files3 · source cached 10/2/2026, 10:26:53 AM

packages/react/src/components/attachment/attachment.tsx
import React from 'react';
import { Slot } from '@radix-ui/react-slot';
import {
	attachmentStyleKeys,
	getAttachmentFileKind,
	getAttachmentMediaStyleKeys,
	getAttachmentStyleKeys,
	formatAttachmentSize,
	isAttachmentActionable,
	truncateAttachmentName,
} from '../lib/core';
import type { AttachmentFileKind, AttachmentMediaVariant, AttachmentOrientation, AttachmentSize, AttachmentState } from '../lib/core';
import styles from './attachment.module.scss';
import { Button } from '../button/button';

/**
 * 按文件类型渲染对应的内联 SVG 图标
 */
export function AttachmentFileIcon({ kind, className }: { kind: AttachmentFileKind; className?: string }) {
	const iconProps = {
		className,
		xmlns: 'http://www.w3.org/2000/svg',
		width: 16,
		height: 16,
		viewBox: '0 0 24 24',
		fill: 'none',
		stroke: 'currentColor',
		strokeWidth: 2,
		strokeLinecap: 'round' as const,
		strokeLinejoin: 'round' as const,
		'aria-hidden': 'true' as const,
	};

	switch (kind) {
		case 'image':
			return (
				<svg {...iconProps}>
					<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
					<circle cx="9" cy="9" r="2" />
					<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
				</svg>
			);
		case 'video':
			return (
				<svg {...iconProps}>
					<path d="m22 8-6 4 6 4V8Z" />
					<rect width="14" height="12" x="2" y="6" rx="2" ry="2" />
				</svg>
			);
		case 'audio':
			return (
				<svg {...iconProps}>
					<path d="M11 5 6 9H2v6h4l5 4V5Z" />
					<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
				</svg>
			);
		case 'pdf':
			return (
				<svg {...iconProps}>
					<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
					<path d="M14 2v6h6" />
					<path d="M9 15c-.5-1 .5-3 2-3s2 2 2 3 0 2-1 2-2.5-.5-3-2Z" />
				</svg>
			);
		case 'spreadsheet':
			return (
				<svg {...iconProps}>
					<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
					<path d="M3 9h18M3 15h18M9 3v18M15 3v18" />
				</svg>
			);
		case 'presentation':
			return (
				<svg {...iconProps}>
					<rect width="18" height="10" x="3" y="3" rx="2" ry="2" />
					<path d="M12 13v5M8 21l4-3 4 3M7 6h10" />
				</svg>
			);
		case 'archive':
			return (
				<svg {...iconProps}>
					<rect width="20" height="5" x="2" y="3" rx="1" />
					<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" />
				</svg>
			);
		case 'code':
			return (
				<svg {...iconProps}>
					<path d="m16 18 6-6-6-6M8 6l-6 6 6 6" />
				</svg>
			);
		default:
			return (
				<svg {...iconProps}>
					<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
					<path d="M14 2v6h6" />
				</svg>
			);
	}
}

/**
 * 下载 / 删除 图标的辅助函数
 */
function DownloadIcon() {
	return (
		<svg
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
			<path d="m7 10 5 5 5-5" />
			<path d="M12 15V3" />
		</svg>
	);
}

function TrashIcon() {
	return (
		<svg
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<path d="M3 6h18" />
			<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
			<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
		</svg>
	);
}

export interface AttachmentProps extends React.HTMLAttributes<HTMLDivElement> {
	state?: AttachmentState;
	size?: AttachmentSize;
	orientation?: AttachmentOrientation;
}

const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>(
	({ className = '', state = 'done', size = 'default', orientation = 'horizontal', children, ...props }, ref) => {
		const styleKeys = getAttachmentStyleKeys(size, orientation);

		return (
			<div
				ref={ref}
				data-slot="attachment"
				data-state={state}
				data-size={size}
				data-orientation={orientation}
				className={`${styles[styleKeys.base]} ${styles[styleKeys.size]} ${styles[styleKeys.orientation]} ${className}`}
				{...props}>
				{children}
			</div>
		);
	},
);
Attachment.displayName = 'Attachment';

export interface AttachmentGroupProps extends React.HTMLAttributes<HTMLDivElement> {}

const AttachmentGroup = React.forwardRef<HTMLDivElement, AttachmentGroupProps>(({ className = '', children, ...props }, ref) => {
	return (
		<div ref={ref} data-slot="attachment-group" className={`${styles[attachmentStyleKeys.group]} ${className}`} {...props}>
			{children}
		</div>
	);
});
AttachmentGroup.displayName = 'AttachmentGroup';

export interface AttachmentMediaProps extends React.HTMLAttributes<HTMLDivElement> {
	variant?: AttachmentMediaVariant;
}

const AttachmentMedia = React.forwardRef<HTMLDivElement, AttachmentMediaProps>(({ className = '', variant = 'icon', children, ...props }, ref) => {
	const styleKeys = getAttachmentMediaStyleKeys(variant);

	return (
		<div ref={ref} data-slot="attachment-media" data-variant={variant} className={`${styles[styleKeys.base]} ${styles[styleKeys.variant]} ${className}`} {...props}>
			{children}
		</div>
	);
});
AttachmentMedia.displayName = 'AttachmentMedia';

export interface AttachmentContentProps extends React.HTMLAttributes<HTMLDivElement> {}

const AttachmentContent = React.forwardRef<HTMLDivElement, AttachmentContentProps>(({ className = '', children, ...props }, ref) => {
	return (
		<div ref={ref} data-slot="attachment-content" className={`${styles[attachmentStyleKeys.content]} ${className}`} {...props}>
			{children}
		</div>
	);
});
AttachmentContent.displayName = 'AttachmentContent';

export interface AttachmentTitleProps extends React.HTMLAttributes<HTMLSpanElement> {}

const AttachmentTitle = React.forwardRef<HTMLSpanElement, AttachmentTitleProps>(({ className = '', children, ...props }, ref) => {
	return (
		<span ref={ref} data-slot="attachment-title" className={`${styles[attachmentStyleKeys.title]} ${className}`} {...props}>
			{children}
		</span>
	);
});
AttachmentTitle.displayName = 'AttachmentTitle';

export interface AttachmentDescriptionProps extends React.HTMLAttributes<HTMLSpanElement> {}

const AttachmentDescription = React.forwardRef<HTMLSpanElement, AttachmentDescriptionProps>(({ className = '', children, ...props }, ref) => {
	return (
		<span ref={ref} data-slot="attachment-description" className={`${styles[attachmentStyleKeys.description]} ${className}`} {...props}>
			{children}
		</span>
	);
});
AttachmentDescription.displayName = 'AttachmentDescription';

export interface AttachmentActionsProps extends React.HTMLAttributes<HTMLDivElement> {}

const AttachmentActions = React.forwardRef<HTMLDivElement, AttachmentActionsProps>(({ className = '', children, ...props }, ref) => {
	return (
		<div ref={ref} data-slot="attachment-actions" className={`${styles[attachmentStyleKeys.actions]} ${className}`} {...props}>
			{children}
		</div>
	);
});
AttachmentActions.displayName = 'AttachmentActions';

export interface AttachmentActionProps extends React.ComponentProps<typeof Button> {}

const AttachmentAction = React.forwardRef<HTMLButtonElement, AttachmentActionProps>(({ className = '', variant, size = 'icon-xs', children, ...props }, ref) => {
	return (
		<Button ref={ref} data-slot="attachment-action" variant={variant ?? 'ghost'} size={size} className={`${styles[attachmentStyleKeys.action]} ${className}`} {...props}>
			{children}
		</Button>
	);
});
AttachmentAction.displayName = 'AttachmentAction';

export interface AttachmentTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
	asChild?: boolean;
}

const AttachmentTrigger = React.forwardRef<HTMLButtonElement, AttachmentTriggerProps>(({ className = '', asChild = false, type, children, ...props }, ref) => {
	const Comp = asChild ? Slot : 'button';

	return (
		<Comp ref={ref} data-slot="attachment-trigger" type={asChild ? undefined : (type ?? 'button')} className={`${styles[attachmentStyleKeys.trigger]} ${className}`} {...props}>
			{children}
		</Comp>
	);
});
AttachmentTrigger.displayName = 'AttachmentTrigger';

export interface AttachmentCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
	name?: string;
	size?: number;
	file?: File;
	state?: AttachmentState;
	orientation?: AttachmentOrientation;
	downloading?: boolean;
	action?: React.ReactNode;
	onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
	onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
	onPreview?: (event: React.MouseEvent<HTMLDivElement>) => void;
}

const AttachmentCard = React.forwardRef<HTMLDivElement, AttachmentCardProps>(
	({ className = '', name, size, file, state = 'done', orientation = 'horizontal', downloading = false, action, onRemove, onDownload, onPreview, onClick, ...props }, ref) => {
		const kind = getAttachmentFileKind(name, file?.type);
		const actionable = isAttachmentActionable(state);
		const displayName = name ? truncateAttachmentName(name) : '';
		const description = size !== undefined ? formatAttachmentSize(size) : state;

		return (
			<Attachment
				ref={ref}
				state={state}
				size="default"
				orientation={orientation}
				className={`${styles[attachmentStyleKeys.card]} ${className}`}
				onClick={(event: React.MouseEvent<HTMLDivElement>) => {
					onClick?.(event);
					onPreview?.(event);
				}}
				{...props}>
				<AttachmentMedia variant="icon">
					<AttachmentFileIcon kind={kind} />
				</AttachmentMedia>
				<AttachmentContent>
					{displayName && <AttachmentTitle>{displayName}</AttachmentTitle>}
					{description && <AttachmentDescription>{description}</AttachmentDescription>}
				</AttachmentContent>
				<AttachmentActions>
					{onDownload && (
						<AttachmentAction type="button" size="icon" aria-label="下载" disabled={!actionable || downloading} onClick={onDownload}>
							<DownloadIcon />
						</AttachmentAction>
					)}
					{action ??
						(onRemove && (
							<AttachmentAction type="button" size="icon" aria-label="删除" disabled={!actionable} onClick={onRemove}>
								<TrashIcon />
							</AttachmentAction>
						))}
				</AttachmentActions>
			</Attachment>
		);
	},
);
AttachmentCard.displayName = 'AttachmentCard';

export {
	Attachment,
	AttachmentGroup,
	AttachmentMedia,
	AttachmentContent,
	AttachmentTitle,
	AttachmentDescription,
	AttachmentActions,
	AttachmentAction,
	AttachmentTrigger,
	AttachmentCard,
};
export default Attachment;
packages/styles/scss/components/attachment.module.scss
@use '../../../styles/variables/colors' as *;

// 附件根容器
.attachment {
	position: relative;
	display: flex;
	width: fit-content;
	max-width: 100%;
	min-width: 0;
	flex-shrink: 0;
	flex-wrap: wrap;
	border: 1px solid $border;
	border-radius: $radius;
	background-color: $card;
	color: $card-foreground;
	transition:
		background-color $transition-fast,
		border-color $transition-fast;

	&:focus-within {
		outline: none;
		box-shadow: 0 0 0 1px color-mix(in srgb, $ring 50%, transparent);
	}

	// 尺寸
	&[data-size='default'] {
		gap: $spacing-2;
		padding-inline: $spacing-2_5;

		.content {
			padding: $spacing-2 0;
		}

		.media {
			padding: $spacing-2 0;
		}
	}

	&[data-size='sm'] {
		gap: $spacing-2_5;
		padding-inline: $spacing-2;

		.content {
			padding: $spacing-1_5 0;
		}

		.media {
			padding: $spacing-1_5 0;
		}
	}

	&[data-size='xs'] {
		gap: $spacing-1_5;
		padding-inline: $spacing-1_5;
		border-radius: $radius-md;

		.content {
			padding: $spacing-1 0;
		}

		.media {
			padding: $spacing-1 0;
		}
	}

	// 排版方向
	&[data-orientation='horizontal'] {
		min-width: 10rem;
		align-items: center;
	}

	&[data-orientation='vertical'] {
		width: 6rem;
		flex-direction: column;

		.content {
			width: 100%;
		}
	}

	// 状态
	&[data-state='idle'] {
		border-style: dashed;
	}

	&[data-state='error'] {
		border-color: color-mix(in srgb, $destructive 30%, transparent);
	}

	&:has(> a, > button):hover {
		background-color: color-mix(in srgb, $muted 50%, transparent);
	}
}

// 附件卡片(AttachmentCard 组合根)
.card {
	position: relative;
	display: flex;
	width: 100%;
	min-width: 0;
	align-items: center;
	gap: $spacing-2;
	border-radius: $radius;
	background-color: $card;
	color: $card-foreground;
	transition:
		background-color $transition-fast,
		border-color $transition-fast;

	&[data-orientation='vertical'] {
		flex-direction: column;
		align-items: flex-start;
	}

	&[data-state='idle'] {
		border: 1px dashed $border;
	}

	&[data-state='error'] {
		border-color: color-mix(in srgb, $destructive 30%, transparent);
	}
}

// 媒体区
.media {
	position: relative;
	display: flex;
	width: 2.5rem;
	height: 2.5rem;
	flex-shrink: 0;
	align-items: center;
	justify-content: center;
	overflow: hidden;
	border-radius: $radius;
	background-color: $muted;
	color: $foreground;

	svg {
		pointer-events: none;
		flex-shrink: 0;
		width: $spacing-4;
		height: $spacing-4;
	}

	// 变体
	&.variantIcon {
		[data-size='xs'] & {
			width: 1.75rem;
			height: 1.75rem;
			border-radius: $radius-md;

			svg {
				width: 0.875rem;
				height: 0.875rem;
			}
		}

		[data-orientation='vertical'] & {
			width: 100%;

			svg {
				width: $spacing-6;
				height: $spacing-6;
			}
		}
	}

	&.variantImage {
		opacity: 0.6;

		img {
			width: 100%;
			height: 100%;
			aspect-ratio: 1 / 1;
			object-fit: cover;
		}

		[data-state='done'] &,
		[data-state='idle'] & {
			opacity: 1;
		}
	}

	// 状态
	[data-state='error'] & {
		background-color: color-mix(in srgb, $destructive 10%, transparent);
		color: $destructive;
	}
}

// 内容区
.content {
	display: flex;
	min-width: 0;
	flex: 1;
	flex-direction: column;
	line-height: $leading-tight;
}

// 标题
.title {
	display: block;
	max-width: 100%;
	min-width: 0;
	overflow: hidden;
	font-weight: $font-medium;
	text-overflow: ellipsis;
	white-space: nowrap;

	[data-state='processing'] &,
	[data-state='uploading'] & {
		color: $muted-foreground;
	}
}

// 描述
.description {
	display: block;
	margin-top: 0.125rem;
	min-width: 0;
	overflow: hidden;
	font-size: $text-xs;
	color: $muted-foreground;
	text-overflow: ellipsis;
	white-space: nowrap;

	[data-state='error'] & {
		color: color-mix(in srgb, $destructive 80%, transparent);
	}
}

// 操作区
.actions {
	position: relative;
	z-index: 20;
	display: flex;
	flex-shrink: 0;
	align-items: center;
	gap: $spacing-1;

	[data-orientation='vertical'] & {
		position: absolute;
		top: $spacing-3;
		right: $spacing-3;
	}
}

// 单个操作按钮
.action {
	display: inline-flex;
	flex-shrink: 0;
	align-items: center;
	justify-content: center;
	border: none;
	border-radius: $radius;
	background-color: transparent;
	color: $muted-foreground;
	cursor: pointer;
	transition:
		background-color $transition-fast,
		color $transition-fast;

	&:hover {
		background-color: $muted;
		color: $foreground;
	}

	&:focus-visible {
		outline: none;
		box-shadow: 0 0 0 1px $ring;
	}

	&:disabled {
		pointer-events: none;
		opacity: 0.5;
	}

	svg {
		width: $spacing-4;
		height: $spacing-4;
	}
}

// 触发层(覆盖整个附件,点击跳转/选择)
.trigger {
	position: absolute;
	inset: 0;
	z-index: 10;
	outline: none;
	cursor: pointer;
}

// 附件组(横向滚动容器)
.group {
	display: flex;
	min-width: 0;
	gap: $spacing-3;
	overflow-x: auto;
	overscroll-behavior-x: contain;
	padding-block: $spacing-1;

	> [data-slot='attachment'],
	> .attachment {
		flex: none;
	}
}
packages/react/src/components/attachment/index.ts
export {
	Attachment,
	AttachmentGroup,
	AttachmentMedia,
	AttachmentContent,
	AttachmentTitle,
	AttachmentDescription,
	AttachmentActions,
	AttachmentAction,
	AttachmentTrigger,
	AttachmentCard,
	AttachmentFileIcon,
	type AttachmentProps,
	type AttachmentGroupProps,
	type AttachmentMediaProps,
	type AttachmentContentProps,
	type AttachmentTitleProps,
	type AttachmentDescriptionProps,
	type AttachmentActionsProps,
	type AttachmentActionProps,
	type AttachmentTriggerProps,
	type AttachmentCardProps,
} from './attachment';

More from Awesome Cursor Rules

Browse all components in this library →