رافع الملفات المحوّلات
استخدام رافع الملفات من slotsmith مع shadcn/ui وMUI وChakra وAnt Design وRadix Themes.
يمكن أيضًا ضبط المحوّل مرة واحدة للتطبيق كله بدلًا من تمريره إلى كل FileUploader: <SlotsmithProvider components={{ fileUploader: shadcnComponents }}>. كيف تُحسم فتحات الموفّر.
shadcn/ui
يعرض هذا المحوّل FileUploader بأجزاء shadcn/ui. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد مكوّنات shadcn/ui هذه، وتضيفها بالأمر npx shadcn@latest add alert avatar button progress. أو ثبّته دفعة واحدة من سجل slotsmith لـ shadcn/ui: أضف "@slotsmith": "https://slotsmith.dev/r/{name}.json" إلى registries في ملف components.json، ثم شغّل npx shadcn@latest add @slotsmith/file-uploader. يكتب الأمر components/slotsmith/file-uploader.tsx وفيه هذه الأجزاء و<FileUploader /> يعرض بها مباشرة، ويضيف مكوّنات shadcn/ui التي يحتاجها. أما npx shadcn@latest add @slotsmith/provider فيطبّق كل المحوّلات على التطبيق كله عبر SlotsmithProvider واحد.
import { FileUploader } from "slotsmith/file-uploader";
import { fakeUpload } from "../../shared/fake-upload";
import { shadcnComponents } from "./shadcn";
export default function ShadcnFileUploader() {
return (
<FileUploader
multiple
accept="image/*,.pdf"
maxSize={5 * 1024 * 1024}
maxFiles={4}
// A stored image, so the item parts show before anything is picked.
defaultValue={["/samples/avatar.jpg"]}
upload={fakeUpload}
components={shadcnComponents}
/>
);
}/** What the uploader hands an `upload` function, as far as this stand-in reads it. */
export interface FakeUploadContext {
signal: AbortSignal;
onProgress(percent: number): void;
}
/** Five steps of 200 ms: the whole upload takes one second. */
const STEPS = 5;
const STEP_MS = 200;
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
clearTimeout(timer);
reject(new DOMException("The upload was cancelled.", "AbortError"));
};
if (signal.aborted) return abort();
timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, ms);
signal.addEventListener("abort", abort, { once: true });
});
}
/**
* A stand-in for an upload endpoint. Nothing leaves the browser: it reports
* progress in five steps over one second, stops as soon as `signal` aborts,
* and resolves with `/samples/uploaded/<name>`. With `fail`, it fails at 60%
* the way a server error would, so the retry path can be tried.
*/
export async function fakeUpload(
file: File,
{ signal, onProgress }: FakeUploadContext,
{ fail = false }: { fail?: boolean } = {},
): Promise<{ url: string }> {
for (let step = 1; step <= STEPS; step += 1) {
await wait(STEP_MS, signal);
if (fail && step === 3) throw new Error("The server answered 503. Try again.");
onProgress((step / STEPS) * 100);
}
return { url: `/samples/uploaded/${encodeURIComponent(file.name)}` };
}العرض داخل موفّر shadcn/ui، كما في أي تطبيق مبني عليها.
shadcn.tsx
/**
* FileUploader for shadcn/ui
*
* A `components` map that renders the slotsmith file uploader with shadcn/ui
* primitives, written for shadcn/ui on Tailwind CSS v4. Copy the file, keep
* the parts you want, and pass the map as `components={shadcnComponents}`;
* every slot left out keeps its fallback.
*
* The `@/components/ui/*` and `@/lib/utils` imports are the app's own
* shadcn/ui files.
*/
import { useState } from "react";
import type {
DropzoneSlotProps,
FileUploaderComponents,
ItemMetaSlotProps,
RejectionsSlotProps,
ThumbnailSlotProps,
UploaderActionSlotProps,
UploaderEmptySlotProps,
UploaderIconSlotProps,
UploaderItemSlotProps,
UploaderListSlotProps,
UploaderProgressSlotProps,
UploaderRootSlotProps,
UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";
import { Alert } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
/**
* Shadcn file uploader parts
*
* Built from shadcn/ui primitives — `Button`, `Avatar`, `Progress`, `Alert` —
* not from a shadcn upload block, which is a copy-in recipe rather than a
* shared primitive.
*
* shadcn's `Button` sets no `type`, so inside a form it would submit it;
* every `Button` here passes `type="button"`.
*/
/** Stands in for `lucide-react`'s Upload icon. */
const UploadIcon = ({ className }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className} aria-hidden="true">
<path d="M12 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</svg>
);
/** Stands in for `lucide-react`'s File icon. */
const FileIcon = ({ className }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className={className} aria-hidden="true">
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
</svg>
);
/** Stands in for `lucide-react`'s X icon. */
const X = ({ className = "size-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className} aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
/** Stands in for `lucide-react`'s CircleAlert icon. */
const CircleAlert = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
);
/** Stands in for `lucide-react`'s RotateCw icon. */
const RotateCw = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-4" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5" />
</svg>
);
/** Stands in for `lucide-react`'s Square icon (used for cancel). */
const Square = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="size-3" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
);
/**
* Shadcn dropzone
*
* The drop target, styled off the `data-*` attributes the part already
* carries — no state of its own.
*/
const ShadcnDropzone = ({ className, ...props }: DropzoneSlotProps) => (
<div
data-slot="dropzone"
className={cn(
"flex flex-col items-center gap-2 rounded-lg border border-dashed p-8 text-center transition-colors",
"hover:border-primary hover:bg-accent/50",
"data-dragging:border-primary data-dragging:bg-accent",
"data-disabled:cursor-not-allowed data-disabled:opacity-55",
className,
)}
{...props}
/>
);
/**
* Shadcn item
*
* The `<li>` for one file, laid out as a grid so the progress bar can span it.
*/
const ShadcnItem = ({ className, ...props }: UploaderItemSlotProps) => (
<li
data-slot="file-item"
className={cn(
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-b py-2 last:border-b-0",
"data-[status=error]:border-destructive/50",
className,
)}
{...props}
/>
);
/**
* Shadcn thumbnail
*
* An `Avatar` holding the preview, or a file glyph in its fallback.
*/
const ShadcnThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) => {
const [broken, setBroken] = useState(false);
const showImage = isImage && !!src && !broken;
return (
<Avatar className="rounded-md">
{showImage && <AvatarImage src={src} alt={alt} onError={() => setBroken(true)} />}
{!showImage && (
<AvatarFallback>
<FileIcon className="size-4" />
</AvatarFallback>
)}
</Avatar>
);
};
/**
* Shadcn item meta
*
* Name over a secondary line, truncated instead of wrapped.
*/
const ShadcnItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
<div className="min-w-0">
<p className="truncate text-sm">{item.name}</p>
<p className={cn("truncate text-xs", item.status === "error" ? "text-destructive" : "text-muted-foreground")}>
{item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
</p>
</div>
);
/**
* Shadcn progress
*
* The `Progress` primitive, spanning the row. shadcn's `Progress` keeps
* `value` for its indicator and never hands it to Radix's root, so the bar
* sets its own `aria-valuenow` for assistive technology.
*/
const ShadcnProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
<Progress
value={value}
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={100}
data-active={active || undefined}
className="col-span-full"
{...aria}
/>
);
/**
* Shadcn action
*
* One ghost icon `Button` for remove, retry and cancel.
*/
const ShadcnAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
{action === "remove" ? <X /> : action === "retry" ? <RotateCw /> : <Square />}
</Button>
);
/**
* Shadcn rejections
*
* A destructive `Alert` led by an icon, which gives its grid two real columns;
* the messages and the Dismiss button share the second, with Dismiss at the
* inline end. The role is `status` with `aria-live="polite"` rather than
* shadcn's `alert`, to match the uploader's contract: refusals are announced
* politely, never assertively.
*/
const ShadcnRejections = ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
<Alert variant="destructive" role="status" aria-live="polite">
<CircleAlert />
<div className="flex items-start gap-2">
<ul className="min-w-0 flex-1 space-y-0.5">
{rejections.map((rejection, index) => (
<li key={`${rejection.file.name}-${index}`}>{rejection.message}</li>
))}
</ul>
<Button
type="button"
variant="ghost"
size="icon"
className="-my-1.5 -me-2 size-7 shrink-0"
aria-label={dismissLabel}
onClick={onDismiss}
>
<X />
</Button>
</div>
</Alert>
);
/**
* Shadcn components
*
* The slot map a shadcn/ui project would pass as `components`.
*/
export const shadcnComponents: Partial<FileUploaderComponents> = {
Root: ({ className, ...props }: UploaderRootSlotProps) => (
<div data-slot="file-uploader" className={cn("flex flex-col gap-3", className)} {...props} />
),
Dropzone: ShadcnDropzone,
Icon: ({ dragging }: UploaderIconSlotProps) => (
<UploadIcon className={cn("size-6", dragging ? "text-primary" : "text-muted-foreground")} />
),
Empty: ({ title, hint }: UploaderEmptySlotProps) => (
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
),
Trigger: ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</Button>
),
List: ({ className, ...props }: UploaderListSlotProps) => (
<ul data-slot="file-list" className={cn("flex flex-col", className)} {...props} />
),
Item: ShadcnItem,
Thumbnail: ShadcnThumbnail,
ItemMeta: ShadcnItemMeta,
Progress: ShadcnProgress,
Action: ShadcnAction,
Rejections: ShadcnRejections,
};MUI
يعرض هذا المحوّل FileUploader بأجزاء MUI. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @mui/material.
import { FileUploader } from "slotsmith/file-uploader";
import { fakeUpload } from "../../shared/fake-upload";
import { muiComponents } from "./mui";
export default function MuiFileUploader() {
return (
<FileUploader
multiple
accept="image/*,.pdf"
maxSize={5 * 1024 * 1024}
maxFiles={4}
// A stored image, so the item parts show before anything is picked.
defaultValue={["/samples/avatar.jpg"]}
upload={fakeUpload}
components={muiComponents}
/>
);
}/** What the uploader hands an `upload` function, as far as this stand-in reads it. */
export interface FakeUploadContext {
signal: AbortSignal;
onProgress(percent: number): void;
}
/** Five steps of 200 ms: the whole upload takes one second. */
const STEPS = 5;
const STEP_MS = 200;
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
clearTimeout(timer);
reject(new DOMException("The upload was cancelled.", "AbortError"));
};
if (signal.aborted) return abort();
timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, ms);
signal.addEventListener("abort", abort, { once: true });
});
}
/**
* A stand-in for an upload endpoint. Nothing leaves the browser: it reports
* progress in five steps over one second, stops as soon as `signal` aborts,
* and resolves with `/samples/uploaded/<name>`. With `fail`, it fails at 60%
* the way a server error would, so the retry path can be tried.
*/
export async function fakeUpload(
file: File,
{ signal, onProgress }: FakeUploadContext,
{ fail = false }: { fail?: boolean } = {},
): Promise<{ url: string }> {
for (let step = 1; step <= STEPS; step += 1) {
await wait(STEP_MS, signal);
if (fail && step === 3) throw new Error("The server answered 503. Try again.");
onProgress((step / STEPS) * 100);
}
return { url: `/samples/uploaded/${encodeURIComponent(file.name)}` };
}العرض داخل موفّر MUI، كما في أي تطبيق مبني عليها.
mui.tsx
/**
* FileUploader for MUI
*
* A `components` map that renders the slotsmith file uploader with MUI
* primitives, tested against MUI v7. Copy the file, keep the parts you want,
* and pass the map as `components={muiComponents}`; every slot left out
* keeps its fallback.
*/
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import LinearProgress from "@mui/material/LinearProgress";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { ReactNode } from "react";
import type {
DropzoneSlotProps,
FileUploaderComponents,
ItemMetaSlotProps,
RejectionsSlotProps,
ThumbnailSlotProps,
UploaderActionSlotProps,
UploaderEmptySlotProps,
UploaderIconSlotProps,
UploaderItemSlotProps,
UploaderListSlotProps,
UploaderProgressSlotProps,
UploaderRootSlotProps,
UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";
/**
* MUI file uploader parts
*
* Built from MUI v7 primitives — `Paper`, `IconButton`, `Avatar`, `List`,
* `ListItem`, `LinearProgress`, `Chip`-adjacent `Alert` — not from MUI's own
* upload component, which does not exist as a shared primitive in the library.
*/
/** A small inline icon, so the skin doesn't pull in `@mui/icons-material`. */
const Glyph = ({ path, size = 20 }: { path: ReactNode; size?: number }) => (
<Box
component="svg"
viewBox="0 0 24 24"
aria-hidden="true"
sx={{ width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8 }}
>
{path}
</Box>
);
/**
* MUI dropzone
*
* An outlined `Paper` that switches to a solid, tinted border while dragging.
*/
const MuiDropzone = (props: DropzoneSlotProps) => (
<Paper
variant="outlined"
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
p: 4,
textAlign: "center",
borderStyle: "dashed",
cursor: "pointer",
transition: "border-color 150ms, background-color 150ms",
"&:hover": { borderColor: "primary.main", bgcolor: "action.hover" },
"&[data-dragging]": { borderStyle: "solid", borderColor: "primary.main", bgcolor: "action.selected" },
"&[data-disabled]": { cursor: "not-allowed", opacity: 0.55 },
}}
{...props}
/>
);
/**
* MUI item
*
* A `ListItem` laid out as a grid so the progress bar can span it.
*/
const MuiItem = (props: UploaderItemSlotProps) => (
<ListItem
divider
sx={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr) auto",
alignItems: "center",
columnGap: 1.5,
rowGap: 1,
"&[data-status=error]": { borderColor: "error.main" },
}}
{...props}
/>
);
/**
* MUI thumbnail
*
* A rounded `Avatar` holding the preview, or a file glyph.
*/
const MuiThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) => (
<Avatar variant="rounded" src={isImage && src ? src : undefined} alt={alt} sx={{ width: 40, height: 40 }}>
{(!isImage || !src) && (
<Glyph path={<path d="M14 3v4a1 1 0 0 0 1 1h4M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />} />
)}
</Avatar>
);
/**
* MUI item meta
*
* Name over a secondary line, truncated instead of wrapped.
*/
const MuiItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap>
{item.name}
</Typography>
<Typography
variant="caption"
color={item.status === "error" ? "error" : "text.secondary"}
noWrap
component="p"
>
{item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
</Typography>
</Box>
);
/**
* MUI progress
*
* `LinearProgress` in determinate mode, spanning the row.
*/
const MuiProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
<LinearProgress
variant="determinate"
value={Math.max(0, Math.min(100, value))}
data-active={active || undefined}
sx={{ gridColumn: "1 / -1", borderRadius: 999 }}
{...aria}
/>
);
/**
* MUI action
*
* One `IconButton` for remove, retry and cancel.
*/
const MuiAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
<IconButton
size="small"
disabled={disabled}
color={action === "remove" ? "default" : "primary"}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
<Glyph
size={18}
path={
action === "remove" ? (
<path d="M18 6 6 18M6 6l12 12" />
) : action === "retry" ? (
<path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5" />
) : (
<rect x="6" y="6" width="12" height="12" rx="1" />
)
}
/>
</IconButton>
);
/**
* MUI components
*
* The slot map an MUI v7 project would pass as `components`.
*/
export const muiComponents: Partial<FileUploaderComponents> = {
Root: (props: UploaderRootSlotProps) => <Stack spacing={1.5} {...props} />,
Dropzone: MuiDropzone,
Icon: ({ dragging }: UploaderIconSlotProps) => (
<Box sx={{ color: dragging ? "primary.main" : "text.secondary", display: "flex" }}>
<Glyph
size={24}
path={
<>
<path d="M12 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</>
}
/>
</Box>
),
Empty: ({ title, hint }: UploaderEmptySlotProps) => (
<Box>
<Typography variant="body2" fontWeight={500}>
{title}
</Typography>
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
</Box>
),
Trigger: ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
<Button
size="small"
variant="outlined"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</Button>
),
List: (props: UploaderListSlotProps) => <List disablePadding {...props} />,
Item: MuiItem,
Thumbnail: MuiThumbnail,
ItemMeta: MuiItemMeta,
Progress: MuiProgress,
Action: MuiAction,
Rejections: ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
<Alert severity="error" onClose={onDismiss} closeText={dismissLabel} role="status">
{rejections.map((rejection, index) => (
<div key={`${rejection.file.name}-${index}`}>{rejection.message}</div>
))}
</Alert>
),
};Chakra UI
يعرض هذا المحوّل FileUploader بأجزاء Chakra UI. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @chakra-ui/react.
import { FileUploader } from "slotsmith/file-uploader";
import { fakeUpload } from "../../shared/fake-upload";
import { chakraComponents } from "./chakra";
export default function ChakraFileUploader() {
return (
<FileUploader
multiple
accept="image/*,.pdf"
maxSize={5 * 1024 * 1024}
maxFiles={4}
// A stored image, so the item parts show before anything is picked.
defaultValue={["/samples/avatar.jpg"]}
upload={fakeUpload}
components={chakraComponents}
/>
);
}/** What the uploader hands an `upload` function, as far as this stand-in reads it. */
export interface FakeUploadContext {
signal: AbortSignal;
onProgress(percent: number): void;
}
/** Five steps of 200 ms: the whole upload takes one second. */
const STEPS = 5;
const STEP_MS = 200;
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
clearTimeout(timer);
reject(new DOMException("The upload was cancelled.", "AbortError"));
};
if (signal.aborted) return abort();
timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, ms);
signal.addEventListener("abort", abort, { once: true });
});
}
/**
* A stand-in for an upload endpoint. Nothing leaves the browser: it reports
* progress in five steps over one second, stops as soon as `signal` aborts,
* and resolves with `/samples/uploaded/<name>`. With `fail`, it fails at 60%
* the way a server error would, so the retry path can be tried.
*/
export async function fakeUpload(
file: File,
{ signal, onProgress }: FakeUploadContext,
{ fail = false }: { fail?: boolean } = {},
): Promise<{ url: string }> {
for (let step = 1; step <= STEPS; step += 1) {
await wait(STEP_MS, signal);
if (fail && step === 3) throw new Error("The server answered 503. Try again.");
onProgress((step / STEPS) * 100);
}
return { url: `/samples/uploaded/${encodeURIComponent(file.name)}` };
}العرض داخل موفّر Chakra UI، كما في أي تطبيق مبني عليها.
chakra.tsx
/**
* FileUploader for Chakra UI
*
* A `components` map that renders the slotsmith file uploader with Chakra UI
* primitives, tested against Chakra UI v3. Copy the file, keep the parts you
* want, and pass the map as `components={chakraComponents}`; every slot left
* out keeps its fallback.
*/
import { Alert, Avatar, Box, Button, CloseButton, IconButton, List, Progress, Text } from "@chakra-ui/react";
import type {
DropzoneSlotProps,
FileUploaderComponents,
ItemMetaSlotProps,
RejectionsSlotProps,
ThumbnailSlotProps,
UploaderActionSlotProps,
UploaderEmptySlotProps,
UploaderIconSlotProps,
UploaderItemSlotProps,
UploaderListSlotProps,
UploaderProgressSlotProps,
UploaderRootSlotProps,
UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";
/**
* Chakra file uploader parts
*
* Built from Chakra UI v3 primitives — `Box`, `Button`, `IconButton`,
* `Avatar`, `Progress`, `Alert` — not from a Chakra upload recipe, which does
* not exist as a shared primitive in the library.
*/
/**
* Chakra dropzone
*
* A dashed `Box`, styled off the `data-*` attributes the part already
* carries.
*/
const ChakraDropzone = (props: DropzoneSlotProps) => (
<Box
display="flex"
flexDirection="column"
alignItems="center"
gap="2"
p="8"
textAlign="center"
borderWidth="1px"
borderStyle="dashed"
borderRadius="md"
cursor="pointer"
css={{
"&:hover": { borderColor: "colorPalette.solid" },
"&[data-dragging]": { borderStyle: "solid", borderColor: "colorPalette.solid", bg: "colorPalette.subtle" },
"&[data-disabled]": { cursor: "not-allowed", opacity: 0.55 },
}}
{...props}
/>
);
/**
* Chakra item
*
* `List.Item` laid out as a grid so the progress bar can span it.
*/
const ChakraItem = (props: UploaderItemSlotProps) => (
<List.Item
listStyleType="none"
display="grid"
gridTemplateColumns="auto minmax(0, 1fr) auto"
alignItems="center"
columnGap="3"
rowGap="1"
py="2"
borderBottomWidth="1px"
css={{ "&:last-of-type": { borderBottomWidth: 0 }, "&[data-status=error]": { borderColor: "border.error" } }}
{...props}
/>
);
/**
* Chakra thumbnail
*
* `Avatar.Root` holding the preview; Chakra's own avatar machine falls back
* to `Avatar.Fallback` whenever there is no image or it fails to load, so
* this slot only decides whether to offer a `src` at all.
*/
const ChakraThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) => (
<Avatar.Root size="md" shape="rounded">
{isImage && src && <Avatar.Image src={src} alt={alt} />}
<Avatar.Fallback>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" width="16" height="16">
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
</svg>
</Avatar.Fallback>
</Avatar.Root>
);
/**
* Chakra item meta
*
* Name over a secondary line, truncated instead of wrapped.
*/
const ChakraItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
<Box minW="0">
<Text truncate fontSize="sm">
{item.name}
</Text>
<Text truncate fontSize="xs" color={item.status === "error" ? "fg.error" : "fg.muted"}>
{item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
</Text>
</Box>
);
/**
* Chakra progress
*
* `Progress.Root` in determinate mode, spanning the row.
*/
const ChakraProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
<Progress.Root
value={value}
min={0}
max={100}
size="xs"
gridColumn="1 / -1"
data-active={active || undefined}
{...aria}
>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
);
/**
* Chakra action
*
* One ghost `IconButton` for remove, retry and cancel.
*/
const ChakraAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
<IconButton
size="xs"
variant="ghost"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
{action === "remove" ? "✕" : action === "retry" ? "⟳" : "■"}
</IconButton>
);
/**
* Chakra components
*
* The slot map a Chakra UI v3 project would pass as `components`.
*/
export const chakraComponents: Partial<FileUploaderComponents> = {
Root: (props: UploaderRootSlotProps) => <Box display="flex" flexDirection="column" gap="3" {...props} />,
Dropzone: ChakraDropzone,
Icon: ({ dragging }: UploaderIconSlotProps) => (
<Box color={dragging ? "colorPalette.solid" : "fg.muted"} display="flex">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" width="24" height="24">
<path d="M12 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</svg>
</Box>
),
Empty: ({ title, hint }: UploaderEmptySlotProps) => (
<Box>
<Text fontSize="sm" fontWeight="medium">
{title}
</Text>
<Text fontSize="xs" color="fg.muted">
{hint}
</Text>
</Box>
),
Trigger: ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
<Button
size="sm"
variant="outline"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</Button>
),
List: (props: UploaderListSlotProps) => <List.Root gap="0" p="0" m="0" {...props} />,
Item: ChakraItem,
Thumbnail: ChakraThumbnail,
ItemMeta: ChakraItemMeta,
Progress: ChakraProgress,
Action: ChakraAction,
Rejections: ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
<Alert.Root status="error" role="status">
<Alert.Content>
{rejections.map((rejection, index) => (
<Alert.Description key={`${rejection.file.name}-${index}`}>{rejection.message}</Alert.Description>
))}
</Alert.Content>
<CloseButton size="xs" aria-label={dismissLabel} onClick={onDismiss} />
</Alert.Root>
),
};Ant Design
يعرض هذا المحوّل FileUploader بأجزاء Ant Design. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @ant-design/icons antd.
import { FileUploader } from "slotsmith/file-uploader";
import { fakeUpload } from "../../shared/fake-upload";
import { antdComponents } from "./antd";
export default function AntdFileUploader() {
return (
<FileUploader
multiple
accept="image/*,.pdf"
maxSize={5 * 1024 * 1024}
maxFiles={4}
// A stored image, so the item parts show before anything is picked.
defaultValue={["/samples/avatar.jpg"]}
upload={fakeUpload}
components={antdComponents}
/>
);
}/** What the uploader hands an `upload` function, as far as this stand-in reads it. */
export interface FakeUploadContext {
signal: AbortSignal;
onProgress(percent: number): void;
}
/** Five steps of 200 ms: the whole upload takes one second. */
const STEPS = 5;
const STEP_MS = 200;
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
clearTimeout(timer);
reject(new DOMException("The upload was cancelled.", "AbortError"));
};
if (signal.aborted) return abort();
timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, ms);
signal.addEventListener("abort", abort, { once: true });
});
}
/**
* A stand-in for an upload endpoint. Nothing leaves the browser: it reports
* progress in five steps over one second, stops as soon as `signal` aborts,
* and resolves with `/samples/uploaded/<name>`. With `fail`, it fails at 60%
* the way a server error would, so the retry path can be tried.
*/
export async function fakeUpload(
file: File,
{ signal, onProgress }: FakeUploadContext,
{ fail = false }: { fail?: boolean } = {},
): Promise<{ url: string }> {
for (let step = 1; step <= STEPS; step += 1) {
await wait(STEP_MS, signal);
if (fail && step === 3) throw new Error("The server answered 503. Try again.");
onProgress((step / STEPS) * 100);
}
return { url: `/samples/uploaded/${encodeURIComponent(file.name)}` };
}العرض داخل موفّر Ant Design، كما في أي تطبيق مبني عليها.
antd.tsx
/**
* FileUploader for Ant Design
*
* A `components` map that renders the slotsmith file uploader with Ant Design
* primitives, tested against Ant Design v6. Copy the file, keep the parts you
* want, and pass the map as `components={antdComponents}`; every slot left
* out keeps its fallback.
*/
import { CloseOutlined, FileOutlined, InboxOutlined, ReloadOutlined, StopOutlined } from "@ant-design/icons";
import { Alert, Avatar, Button, Flex, Progress, Typography, theme } from "antd";
import type {
DropzoneSlotProps,
FileUploaderComponents,
ItemMetaSlotProps,
RejectionsSlotProps,
ThumbnailSlotProps,
UploaderActionSlotProps,
UploaderEmptySlotProps,
UploaderIconSlotProps,
UploaderItemSlotProps,
UploaderListSlotProps,
UploaderProgressSlotProps,
UploaderRootSlotProps,
UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";
/**
* Ant Design file uploader parts
*
* Built from Ant Design v6 primitives — `Button`, `Avatar`, `Progress`,
* `Alert`, `Typography` and Ant's icons — not from Ant's own `Upload`, which
* is a competing engine rather than a set of parts. The element parts take
* the look of Ant's dragger and upload list from the theme's tokens; their
* state arrives as `data-*` props, so each part reads its own and picks the
* matching token.
*/
/**
* Has flag
*
* @param props - An element part's props.
* @param name - A `data-*` attribute.
* @returns Whether the component set it.
*/
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;
/**
* Ant dropzone
*
* Ant's dragger: a dashed, lightly filled panel whose border turns primary
* while a file is over it.
*/
function AntDropzone({ style, ...props }: DropzoneSlotProps) {
const { token } = theme.useToken();
const dragging = has(props, "data-dragging");
const disabled = has(props, "data-disabled");
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: token.paddingXS,
padding: token.paddingLG,
textAlign: "center",
background: token.colorFillAlter,
border: `${token.lineWidth}px dashed ${dragging ? token.colorPrimary : token.colorBorder}`,
borderRadius: token.borderRadiusLG,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : undefined,
transition: `border-color ${token.motionDurationSlow}`,
...style,
}}
{...props}
/>
);
}
/**
* Ant icon
*
* The dragger's inbox, in the primary colour while a file is over the zone.
*/
function AntIcon({ dragging }: UploaderIconSlotProps) {
const { token } = theme.useToken();
return (
<span aria-hidden="true" style={{ display: "flex", fontSize: token.fontSizeHeading2, color: dragging ? token.colorPrimary : token.colorTextSecondary }}>
<InboxOutlined />
</span>
);
}
/**
* Ant empty
*
* The dragger's title over its secondary hint.
*/
const AntEmpty = ({ title, hint }: UploaderEmptySlotProps) => (
<Flex vertical>
<Typography.Text strong>{title}</Typography.Text>
<Typography.Text type="secondary">{hint}</Typography.Text>
</Flex>
);
/**
* Ant trigger
*
* A small `Button`. The click is stopped so it does not also reach the drop
* zone and open the dialog twice.
*/
const AntTrigger = ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
<Button
size="small"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</Button>
);
/**
* Ant list
*
* The `<ul>` of Ant's upload list.
*/
const AntList = ({ style, ...props }: UploaderListSlotProps) => (
<ul style={{ margin: 0, padding: 0, listStyle: "none", ...style }} {...props} />
);
/**
* Ant item
*
* A row of Ant's picture list: a grid so the progress bar can span it, and
* the error colour on the border of a failed file.
*/
function AntItem({ style, ...props }: UploaderItemSlotProps) {
const { token } = theme.useToken();
const failed = (props as Record<string, unknown>)["data-status"] === "error";
return (
<li
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr) auto",
alignItems: "center",
columnGap: token.paddingSM,
rowGap: token.paddingXS,
marginTop: token.marginXS,
padding: token.paddingXS,
border: `${token.lineWidth}px ${token.lineType} ${failed ? token.colorError : token.colorBorder}`,
borderRadius: token.borderRadiusLG,
...style,
}}
{...props}
/>
);
}
/**
* Ant thumbnail
*
* A square `Avatar` holding the preview, or a file icon.
*/
const AntThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) =>
isImage && src ? (
<Avatar shape="square" size={40} src={src} alt={alt} />
) : (
<Avatar shape="square" size={40} icon={<FileOutlined aria-hidden="true" />} />
);
/**
* Ant item meta
*
* Name over a secondary line, cut short instead of wrapped; the error in the
* danger colour.
*/
const AntItemMeta = ({ item, size, status }: ItemMetaSlotProps) => {
const line = { display: "block", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" } as const;
return (
<div style={{ minWidth: 0 }}>
<Typography.Text style={line}>{item.name}</Typography.Text>
<Typography.Text type={item.status === "error" ? "danger" : "secondary"} style={{ ...line, fontSize: "0.85em" }}>
{item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
</Typography.Text>
</div>
);
};
/**
* Ant progress
*
* A thin line `Progress` without its number, spanning the row, animated
* while the upload is in flight.
*/
const AntProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
<Progress
percent={Math.max(0, Math.min(100, value))}
size="small"
showInfo={false}
status={active ? "active" : "normal"}
style={{ gridColumn: "1 / -1", margin: 0 }}
{...aria}
/>
);
/**
* Ant action
*
* One text `Button` for remove, retry and cancel; retry and cancel in the
* primary colour. Ant's `color` prop is a palette, which is why the slot
* passes no DOM `color`.
*/
const AntAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
<Button
size="small"
variant="text"
color={action === "remove" ? "default" : "primary"}
disabled={disabled}
icon={action === "remove" ? <CloseOutlined /> : action === "retry" ? <ReloadOutlined /> : <StopOutlined />}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
/>
);
/**
* Ant rejections
*
* An error `Alert` listing the refused files. Announced politely, so `role`
* is `status` rather than Ant's default `alert`; the `closable` object names
* the close button.
*/
const AntRejections = ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
<Alert
type="error"
showIcon
role="status"
closable={{ onClose: onDismiss, "aria-label": dismissLabel }}
title={rejections.map((rejection, index) => (
<div key={`${rejection.file.name}-${index}`}>{rejection.message}</div>
))}
/>
);
/**
* Ant Design components
*
* The slot map an Ant Design v6 project would pass as `components`.
*/
export const antdComponents: Partial<FileUploaderComponents> = {
Root: ({ style, ...props }: UploaderRootSlotProps) => (
<div style={{ display: "flex", flexDirection: "column", gap: 12, ...style }} {...props} />
),
Dropzone: AntDropzone,
Icon: AntIcon,
Empty: AntEmpty,
Trigger: AntTrigger,
List: AntList,
Item: AntItem,
Thumbnail: AntThumbnail,
ItemMeta: AntItemMeta,
Progress: AntProgress,
Action: AntAction,
Rejections: AntRejections,
};Radix Themes
يعرض هذا المحوّل FileUploader بأجزاء Radix Themes. انسخ الملف إلى تطبيقك ومرّر الخريطة إلى components، وكل فتحة لا تمرّرها تبقى على بديلها الافتراضي. يستورد @radix-ui/react-icons @radix-ui/themes.
import { FileUploader } from "slotsmith/file-uploader";
import { fakeUpload } from "../../shared/fake-upload";
import { radixComponents } from "./radix";
export default function RadixFileUploader() {
return (
<FileUploader
multiple
accept="image/*,.pdf"
maxSize={5 * 1024 * 1024}
maxFiles={4}
// A stored image, so the item parts show before anything is picked.
defaultValue={["/samples/avatar.jpg"]}
upload={fakeUpload}
components={radixComponents}
/>
);
}/** What the uploader hands an `upload` function, as far as this stand-in reads it. */
export interface FakeUploadContext {
signal: AbortSignal;
onProgress(percent: number): void;
}
/** Five steps of 200 ms: the whole upload takes one second. */
const STEPS = 5;
const STEP_MS = 200;
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
clearTimeout(timer);
reject(new DOMException("The upload was cancelled.", "AbortError"));
};
if (signal.aborted) return abort();
timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, ms);
signal.addEventListener("abort", abort, { once: true });
});
}
/**
* A stand-in for an upload endpoint. Nothing leaves the browser: it reports
* progress in five steps over one second, stops as soon as `signal` aborts,
* and resolves with `/samples/uploaded/<name>`. With `fail`, it fails at 60%
* the way a server error would, so the retry path can be tried.
*/
export async function fakeUpload(
file: File,
{ signal, onProgress }: FakeUploadContext,
{ fail = false }: { fail?: boolean } = {},
): Promise<{ url: string }> {
for (let step = 1; step <= STEPS; step += 1) {
await wait(STEP_MS, signal);
if (fail && step === 3) throw new Error("The server answered 503. Try again.");
onProgress((step / STEPS) * 100);
}
return { url: `/samples/uploaded/${encodeURIComponent(file.name)}` };
}العرض داخل موفّر Radix Themes، كما في أي تطبيق مبني عليها.
radix.tsx
/**
* FileUploader for Radix Themes
*
* A `components` map that renders the slotsmith file uploader with Radix
* Themes primitives, tested against Radix Themes v3. Copy the file, keep the
* parts you want, and pass the map as `components={radixComponents}`; every
* slot left out keeps its fallback.
*
* This is Radix Themes (`@radix-ui/themes`), the styled library; an app on the
* bare Radix primitives wants the shadcn/ui adapter, which is built on them.
*/
import { Cross2Icon, ExclamationTriangleIcon, FileIcon, ReloadIcon, StopIcon, UploadIcon } from "@radix-ui/react-icons";
import { Avatar, Button, Callout, Flex, IconButton, Progress, Text } from "@radix-ui/themes";
import type {
DropzoneSlotProps,
FileUploaderComponents,
ItemMetaSlotProps,
RejectionsSlotProps,
ThumbnailSlotProps,
UploaderActionSlotProps,
UploaderEmptySlotProps,
UploaderIconSlotProps,
UploaderItemSlotProps,
UploaderListSlotProps,
UploaderProgressSlotProps,
UploaderRootSlotProps,
UploaderTriggerSlotProps,
} from "slotsmith/file-uploader";
/**
* Radix Themes file uploader parts
*
* Built from Radix Themes v3 — `Button`, `IconButton`, `Avatar`,
* `Progress`, `Callout`, `Text` and Radix icons. Radix Themes has no upload
* component, so the drop zone and the rows are plain elements dressed in its
* variables (`--accent-*`, `--gray-*`, `--red-*`), which makes them follow the
* app's `<Theme accentColor appearance>`; their state arrives as `data-*`
* props, so each part reads its own. The app must import
* `@radix-ui/themes/styles.css`, as every Radix Themes app already does.
*
* Radix's `Button` and `IconButton` render a `<button>` with no `type`, which
* would submit a surrounding form, so each one here is given
* `type="button"` ahead of the slot's props.
*/
/**
* Has flag
*
* @param props - An element part's props.
* @param name - A `data-*` attribute.
* @returns Whether the component set it.
*/
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;
/**
* Radix dropzone
*
* A dashed, lightly filled panel whose border and fill take the accent
* colour while a file is over it.
*/
function RadixDropzone({ style, ...props }: DropzoneSlotProps) {
const dragging = has(props, "data-dragging");
const disabled = has(props, "data-disabled");
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "var(--space-2)",
padding: "var(--space-5)",
textAlign: "center",
fontFamily: "var(--default-font-family)",
backgroundColor: dragging ? "var(--accent-a2)" : "var(--gray-a2)",
borderWidth: 1,
borderStyle: "dashed",
borderColor: dragging ? "var(--accent-8)" : "var(--gray-a7)",
borderRadius: "var(--radius-4)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : undefined,
transition: "border-color 150ms, background-color 150ms",
...style,
}}
{...props}
/>
);
}
/**
* Radix icon
*
* An upload arrow, in the accent colour while a file is over the zone.
*/
const RadixIcon = ({ dragging }: UploaderIconSlotProps) => (
<span aria-hidden="true" style={{ display: "flex", color: dragging ? "var(--accent-11)" : "var(--gray-a11)" }}>
<UploadIcon width={24} height={24} />
</span>
);
/**
* Radix empty
*
* The title over a muted hint.
*/
const RadixEmpty = ({ title, hint }: UploaderEmptySlotProps) => (
<Flex direction="column">
<Text size="2" weight="medium">
{title}
</Text>
<Text size="2" color="gray">
{hint}
</Text>
</Flex>
);
/**
* Radix trigger
*
* A soft `Button`. The click is stopped so it does not also reach the drop
* zone and open the dialog twice.
*/
const RadixTrigger = ({ onClick, disabled, children }: UploaderTriggerSlotProps) => (
<Button
type="button"
size="1"
variant="soft"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{children}
</Button>
);
/**
* Radix list
*
* The `<ul>` of rows.
*/
const RadixList = ({ style, ...props }: UploaderListSlotProps) => (
<ul style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)", margin: 0, padding: 0, listStyle: "none", ...style }} {...props} />
);
/**
* Radix item
*
* A row like a Radix surface card: a grid so the progress bar can span it,
* and a red hairline around a failed file.
*/
function RadixItem({ style, ...props }: UploaderItemSlotProps) {
const failed = (props as Record<string, unknown>)["data-status"] === "error";
return (
<li
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr) auto",
alignItems: "center",
columnGap: "var(--space-3)",
rowGap: "var(--space-2)",
padding: "var(--space-2)",
fontFamily: "var(--default-font-family)",
backgroundColor: "var(--color-surface)",
boxShadow: `inset 0 0 0 1px ${failed ? "var(--red-a7)" : "var(--gray-a5)"}`,
borderRadius: "var(--radius-3)",
...style,
}}
{...props}
/>
);
}
/**
* Radix thumbnail
*
* The preview in `Avatar`'s rounded square, or an `Avatar` holding a file
* icon. Radix's `Avatar` draws its image only once it has loaded and shows
* no `alt` until then, so the preview is a plain `<img>` of the same size and
* radius, which keeps its accessible name from the first render.
*/
const RadixThumbnail = ({ src, isImage, alt }: ThumbnailSlotProps) =>
isImage && src ? (
<img
src={src}
alt={alt}
style={{ display: "block", width: "var(--space-7)", height: "var(--space-7)", objectFit: "cover", borderRadius: "var(--radius-2)" }}
/>
) : (
<Avatar size="3" variant="soft" color="gray" fallback={<FileIcon aria-hidden="true" width={18} height={18} />} />
);
/**
* Radix item meta
*
* Name over a muted line, cut short instead of wrapped; the error in red.
*/
const RadixItemMeta = ({ item, size, status }: ItemMetaSlotProps) => (
<Flex direction="column" minWidth="0">
<Text size="2" truncate>
{item.name}
</Text>
<Text size="1" truncate color={item.status === "error" ? "red" : "gray"}>
{item.status === "error" ? item.error : [size, status].filter(Boolean).join(" · ")}
</Text>
</Flex>
);
/**
* Radix progress
*
* A thin `Progress` spanning the row, in the gray scale once the upload is
* no longer in flight.
*/
const RadixProgress = ({ value, active, ...aria }: UploaderProgressSlotProps) => (
<Progress
size="1"
value={Math.max(0, Math.min(100, value))}
color={active ? undefined : "gray"}
style={{ gridColumn: "1 / -1" }}
{...aria}
/>
);
/**
* Radix action
*
* One ghost `IconButton` for remove, retry and cancel; remove in gray, retry
* and cancel in the accent colour.
*/
const RadixAction = ({ action, onClick, disabled, ...aria }: UploaderActionSlotProps) => (
<IconButton
type="button"
size="1"
variant="ghost"
color={action === "remove" ? "gray" : undefined}
disabled={disabled}
style={{ margin: 0 }}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
{action === "remove" ? <Cross2Icon /> : action === "retry" ? <ReloadIcon /> : <StopIcon />}
</IconButton>
);
/**
* Radix rejections
*
* A red `Callout` listing the refused files, with a ghost `IconButton` to
* dismiss it. Announced politely, so its `role` is `status`. `Callout.Text`
* is a paragraph, so each file gets a block `<span>` rather than a `<div>`.
*/
const RadixRejections = ({ rejections, onDismiss, dismissLabel }: RejectionsSlotProps) => (
<Callout.Root color="red" size="1" role="status">
<Callout.Icon>
<ExclamationTriangleIcon />
</Callout.Icon>
<Flex align="start" justify="between" gap="2">
<Callout.Text>
{rejections.map((rejection, index) => (
<span key={`${rejection.file.name}-${index}`} style={{ display: "block" }}>
{rejection.message}
</span>
))}
</Callout.Text>
<IconButton type="button" size="1" variant="ghost" color="red" aria-label={dismissLabel} onClick={onDismiss} style={{ margin: 0 }}>
<Cross2Icon />
</IconButton>
</Flex>
</Callout.Root>
);
/**
* Radix Themes components
*
* The slot map a Radix Themes v3 project would pass as `components`.
*/
export const radixComponents: Partial<FileUploaderComponents> = {
Root: ({ style, ...props }: UploaderRootSlotProps) => (
<div style={{ display: "flex", flexDirection: "column", gap: 12, ...style }} {...props} />
),
Dropzone: RadixDropzone,
Icon: RadixIcon,
Empty: RadixEmpty,
Trigger: RadixTrigger,
List: RadixList,
Item: RadixItem,
Thumbnail: RadixThumbnail,
ItemMeta: RadixItemMeta,
Progress: RadixProgress,
Action: RadixAction,
Rejections: RadixRejections,
};