@ponti-studios/ui 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/components/data-display/accordion.tsx +10 -1
  3. package/src/components/feedback/empty-state.tsx +1 -1
  4. package/src/components/feedback/file-upload-status.tsx +14 -6
  5. package/src/components/feedback/progress-bar.tsx +3 -2
  6. package/src/components/feedback/spinner.tsx +3 -4
  7. package/src/components/feedback/update-guard.stories.tsx +1 -1
  8. package/src/components/feedback/update-guard.tsx +2 -2
  9. package/src/components/filters/active-filters-bar.stories.tsx +2 -0
  10. package/src/components/filters/active-filters-bar.tsx +21 -23
  11. package/src/components/filters/filter-select.tsx +4 -2
  12. package/src/components/filters/index.ts +0 -4
  13. package/src/components/forms/date-month-select.tsx +2 -2
  14. package/src/components/forms/date-picker.tsx +2 -2
  15. package/src/components/forms/drop-zone.stories.tsx +112 -28
  16. package/src/components/forms/drop-zone.tsx +265 -112
  17. package/src/components/forms/input.tsx +5 -1
  18. package/src/components/forms/radio-group.tsx +3 -2
  19. package/src/components/forms/select.tsx +14 -4
  20. package/src/components/forms/slider.stories.tsx +2 -2
  21. package/src/components/forms/slider.tsx +12 -9
  22. package/src/components/forms/switch.tsx +5 -2
  23. package/src/components/forms/textarea.tsx +5 -1
  24. package/src/components/layout/index.ts +0 -2
  25. package/src/components/layout/section-intro.tsx +1 -1
  26. package/src/components/layout/surface-panel.tsx +10 -2
  27. package/src/components/navigation/tabs.tsx +4 -1
  28. package/src/components/overlays/command.tsx +39 -14
  29. package/src/components/overlays/popover.stories.tsx +16 -4
  30. package/src/components/primitives/badge.tsx +1 -1
  31. package/src/components/primitives/button.tsx +6 -1
  32. package/src/components/filters/filters.stories.tsx +0 -72
  33. package/src/components/filters/filters.tsx +0 -72
  34. package/src/components/filters/search-filter-bar.stories.tsx +0 -39
  35. package/src/components/filters/search-filter-bar.tsx +0 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponti-studios/ui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "description": "Shared UI component library for Ponti Studios",
6
6
  "license": "MIT",
@@ -6,8 +6,9 @@ import { cn } from "../../lib/utils";
6
6
 
7
7
  function Accordion({
8
8
  type,
9
- collapsible: _collapsible,
9
+ collapsible = true,
10
10
  className,
11
+ onValueChange,
11
12
  ...props
12
13
  }: React.ComponentProps<typeof AccordionPrimitive.Root> & {
13
14
  type?: "single" | "multiple";
@@ -17,6 +18,14 @@ function Accordion({
17
18
  <AccordionPrimitive.Root
18
19
  data-slot="accordion"
19
20
  multiple={type === "multiple"}
21
+ onValueChange={(value, details) => {
22
+ if (type !== "multiple" && !collapsible && value.length === 0) {
23
+ details.cancel();
24
+ return;
25
+ }
26
+
27
+ onValueChange?.(value, details);
28
+ }}
20
29
  {...props}
21
30
  className={cn("w-full", className)}
22
31
  />
@@ -40,7 +40,7 @@ export function EmptyState({
40
40
  variant === "default" && "bg-card border",
41
41
  variant === "dashed" && "bg-card border border-dashed",
42
42
  variant === "search" && "bg-muted border border-dashed",
43
- variant === "quiet" && "bg-surface",
43
+ variant === "quiet" && "bg-card",
44
44
  centered
45
45
  ? cn(
46
46
  "flex flex-col items-center justify-center text-center",
@@ -1,7 +1,6 @@
1
1
  import { XIcon } from "lucide-react";
2
2
  import { memo } from "react";
3
3
 
4
- import { cn } from "../../lib/utils";
5
4
  import { ProgressBar } from "./progress-bar";
6
5
 
7
6
  export interface FileUploadStats {
@@ -47,11 +46,12 @@ export const FileUploadStatus = memo(function FileUploadStatus({
47
46
  {typeof progress === "number" ? (
48
47
  <ProgressBar
49
48
  progress={progress}
50
- className={cn("bg-muted h-2", "before:bg-emphasis-high")}
49
+ className="bg-muted h-2"
50
+ indicatorClassName="border-primary"
51
51
  />
52
52
  ) : (
53
53
  <div className="bg-muted h-2 overflow-hidden">
54
- <div className="bg-emphasis-high void-anim-breezy-progress h-full" />
54
+ <div className="bg-primary void-anim-breezy-progress h-full" />
55
55
  </div>
56
56
  )}
57
57
  </div>
@@ -66,7 +66,11 @@ export const FileUploadStatus = memo(function FileUploadStatus({
66
66
  <span className="text-muted-foreground text-sm font-medium">Queue position</span>
67
67
  </div>
68
68
  <div className="w-full">
69
- <ProgressBar progress={0} className="bg-warning-subtle before:bg-warning h-2" />
69
+ <ProgressBar
70
+ progress={0}
71
+ className="bg-warning/10 h-2"
72
+ indicatorClassName="border-warning"
73
+ />
70
74
  </div>
71
75
  </output>
72
76
  );
@@ -76,7 +80,11 @@ export const FileUploadStatus = memo(function FileUploadStatus({
76
80
  return (
77
81
  <output className="space-y-3">
78
82
  <div className="w-full">
79
- <ProgressBar progress={100} className="bg-emphasis-minimal before:bg-emphasis-high h-2" />
83
+ <ProgressBar
84
+ progress={100}
85
+ className="bg-muted h-2"
86
+ indicatorClassName="border-primary"
87
+ />
80
88
  </div>
81
89
  {stats && <ProcessingStats stats={stats} />}
82
90
  </output>
@@ -87,7 +95,7 @@ export const FileUploadStatus = memo(function FileUploadStatus({
87
95
  return (
88
96
  <output className="mt-2" role="alert">
89
97
  <div className="bg-destructive/10 text-destructive-text flex items-start gap-2 p-3">
90
- <XIcon className="mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden="true" />
98
+ <XIcon className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
91
99
  <span className="text-sm">{error}</span>
92
100
  </div>
93
101
  </output>
@@ -2,15 +2,16 @@ import { cn } from "../../lib/utils";
2
2
 
3
3
  export interface ProgressBarProps {
4
4
  className?: string;
5
+ indicatorClassName?: string;
5
6
  progress?: number;
6
7
  }
7
8
 
8
9
  /** Thin linear progress indicator, distinct from the Radix-backed `Progress`. */
9
- export function ProgressBar({ className, progress = 0 }: ProgressBarProps) {
10
+ export function ProgressBar({ className, indicatorClassName, progress = 0 }: ProgressBarProps) {
10
11
  return (
11
12
  <div className={cn("border-border h-[2px] w-full overflow-hidden border-t", className)}>
12
13
  <div
13
- className={cn("border-warning h-full border-t")}
14
+ className={cn("border-warning h-full border-t", indicatorClassName)}
14
15
  style={{
15
16
  width: `${Math.min(100, Math.max(0, progress))}%`,
16
17
  opacity: progress === 100 ? 0 : 0.8,
@@ -9,7 +9,7 @@ const sizeClasses = {
9
9
  xl: "size-12",
10
10
  } as const;
11
11
 
12
- export interface SpinnerProps extends React.ComponentProps<"div"> {
12
+ export interface SpinnerProps extends React.ComponentProps<"output"> {
13
13
  label?: string | false;
14
14
  presentation?: "inline" | "centered";
15
15
  size?: keyof typeof sizeClasses;
@@ -25,13 +25,12 @@ export function Spinner({
25
25
  const accessibleLabel = typeof label === "string" ? label : "Loading";
26
26
 
27
27
  return (
28
- <div
28
+ <output
29
29
  className={cn(
30
30
  "inline-flex items-center justify-center gap-2",
31
31
  presentation === "centered" && "flex w-full py-12",
32
32
  className,
33
33
  )}
34
- role="status"
35
34
  aria-label={accessibleLabel}
36
35
  {...props}
37
36
  >
@@ -43,6 +42,6 @@ export function Spinner({
43
42
  aria-hidden="true"
44
43
  />
45
44
  {label && <span className="text-muted-foreground text-xs">{label}</span>}
46
- </div>
45
+ </output>
47
46
  );
48
47
  }
@@ -23,7 +23,7 @@ export const Default: Story = {
23
23
  args: {
24
24
  hideInDev: false,
25
25
  children: (
26
- <div className="border-border bg-surface text-muted-foreground rounded-md border p-6 text-sm">
26
+ <div className="border-border bg-card text-muted-foreground rounded-md border p-6 text-sm">
27
27
  App content renders here. Offline/update prompts appear fixed to the bottom of the viewport
28
28
  when the service worker reports a change.
29
29
  </div>
@@ -225,7 +225,7 @@ function UpdateGuardClient({
225
225
  <>
226
226
  {offlineMessage ? (
227
227
  <div className="fixed inset-x-0 bottom-16 z-50 flex justify-center px-4">
228
- <div className="border-border bg-surface flex items-center gap-3 rounded-md border px-4 py-2">
228
+ <div className="border-border bg-card flex items-center gap-3 rounded-md border px-4 py-2">
229
229
  <span className="text-foreground text-sm">{offlineMessage}</span>
230
230
  </div>
231
231
  </div>
@@ -233,7 +233,7 @@ function UpdateGuardClient({
233
233
 
234
234
  {offlineReady || needRefresh ? (
235
235
  <div className="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
236
- <div className="border-border bg-surface flex items-center gap-3 rounded-md border px-4 py-2">
236
+ <div className="border-border bg-card flex items-center gap-3 rounded-md border px-4 py-2">
237
237
  <span className="text-foreground text-sm">
238
238
  {offlineReady
239
239
  ? (copy?.offlineReady ?? "App ready to work offline")
@@ -26,6 +26,7 @@ export const Default: Story = {
26
26
  { id: "1", label: "Status: Active", onRemove: fn() },
27
27
  { id: "2", label: "Created after Jan 1", onRemove: fn() },
28
28
  ],
29
+ onClearAll: fn(),
29
30
  },
30
31
  play: async ({ canvasElement }) => {
31
32
  const canvas = within(canvasElement);
@@ -62,6 +63,7 @@ export const WithClearAll: Story = {
62
63
  export const WithSortOnly: Story = {
63
64
  args: {
64
65
  filters: [{ id: "1", label: "Date", onRemove: fn(), variant: "sort", direction: "desc" }],
66
+ onClearAll: fn(),
65
67
  },
66
68
  play: async ({ canvasElement }) => {
67
69
  const canvas = within(canvasElement);
@@ -33,31 +33,29 @@ export function ActiveFiltersBar({ filters, className, onClearAll }: ActiveFilte
33
33
  <span className="text-muted-foreground shrink-0 self-stretch flex items-center text-sm border-r px-4">
34
34
  Filters
35
35
  </span>
36
- <div
37
- role="list"
38
- className="flex flex-1 min-w-0 items-center gap-2 overflow-x-auto scroll-fade-x pl-4 py-1.5"
39
- >
36
+ <ul className="flex flex-1 min-w-0 items-center gap-2 overflow-x-auto scroll-fade-x pl-4 py-1.5">
40
37
  {filters.map((filter) => (
41
- <FilterChip
42
- key={filter.id}
43
- role="listitem"
44
- className="shrink-0"
45
- label={filter.label}
46
- onRemove={filter.onRemove}
47
- onClick={filter.onClick}
48
- variant={filter.variant === "sort" ? "sort" : "default"}
49
- direction={filter.direction}
50
- />
38
+ <li key={filter.id} className="shrink-0">
39
+ <FilterChip
40
+ label={filter.label}
41
+ onRemove={filter.onRemove}
42
+ onClick={filter.onClick}
43
+ variant={filter.variant === "sort" ? "sort" : "default"}
44
+ direction={filter.direction}
45
+ />
46
+ </li>
51
47
  ))}
52
- </div>
53
- <button
54
- type="button"
55
- onClick={onClearAll}
56
- className="hover:bg-muted text-muted-foreground hover:text-foreground inline-flex shrink-0 items-center justify-center self-stretch aspect-square rounded-r-lg border-l border-border transition-colors"
57
- aria-label="Clear all"
58
- >
59
- <X className="size-4" />
60
- </button>
48
+ </ul>
49
+ {onClearAll ? (
50
+ <button
51
+ type="button"
52
+ onClick={onClearAll}
53
+ className="hover:bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex shrink-0 items-center justify-center self-stretch aspect-square rounded-r-lg border-l border-border transition-colors focus-visible:ring-2 focus-visible:outline-none"
54
+ aria-label="Clear all"
55
+ >
56
+ <X className="size-4" aria-hidden />
57
+ </button>
58
+ ) : null}
61
59
  </div>
62
60
  );
63
61
  }
@@ -14,6 +14,7 @@ export interface FilterSelectProps {
14
14
  onChange: (value: string) => void;
15
15
  options: FilterSelectOption[];
16
16
  label: string;
17
+ loadingText?: string;
17
18
  placeholder?: string;
18
19
  disabled?: boolean;
19
20
  isLoading?: boolean;
@@ -29,6 +30,7 @@ export function FilterSelect({
29
30
  value,
30
31
  options,
31
32
  onChange,
33
+ loadingText = "Loading...",
32
34
  placeholder = "All",
33
35
  disabled = false,
34
36
  isLoading = false,
@@ -44,7 +46,7 @@ export function FilterSelect({
44
46
  );
45
47
 
46
48
  return (
47
- <div className={cn("space-y-2 min-w-36 bg-background", className)}>
49
+ <div className={cn("space-y-2 min-w-36", className)}>
48
50
  <Label htmlFor={selectId}>{label}</Label>
49
51
  <Select value={value} onValueChange={onChange} items={items}>
50
52
  <SelectTrigger id={selectId} disabled={disabled} aria-label={label} className="w-full">
@@ -53,7 +55,7 @@ export function FilterSelect({
53
55
  <SelectContent className="bg-background max-h-[250px] min-w-36 overflow-y-auto">
54
56
  <SelectItem value={ALL_VALUE}>{placeholder}</SelectItem>
55
57
  {isLoading ? (
56
- <div className="text-muted-foreground px-2 py-1.5 text-sm">Loading…</div>
58
+ <div className="text-muted-foreground px-2 py-1.5 text-sm">{loadingText}</div>
57
59
  ) : options.length === 0 ? (
58
60
  <div className="text-muted-foreground px-2 py-1.5 text-sm">{emptyLabel}</div>
59
61
  ) : (
@@ -4,7 +4,3 @@ export { FilterChip } from "./filter-chip";
4
4
  export type { FilterChipProps } from "./filter-chip";
5
5
  export { FilterSelect } from "./filter-select";
6
6
  export type { FilterSelectOption, FilterSelectProps } from "./filter-select";
7
- export { Filters } from "./filters";
8
- export type { FiltersProps } from "./filters";
9
- export { SearchFilterBar } from "./search-filter-bar";
10
- export type { SearchFilterBarProps } from "./search-filter-bar";
@@ -42,7 +42,7 @@ export function DateMonthSelect({
42
42
 
43
43
  return (
44
44
  <div className="flex items-center">
45
- <div className="flex items-center justify-center size-10 border rounded-l-xl bg-surface-2 text-text-1">
45
+ <div className="border-border bg-background text-foreground flex size-10 items-center justify-center rounded-l-xl border">
46
46
  <Calendar className="size-4" />
47
47
  </div>
48
48
  <Select
@@ -51,7 +51,7 @@ export function DateMonthSelect({
51
51
  >
52
52
  <SelectTrigger
53
53
  className={cn(
54
- "rounded-r-xl rounded-l-none min-h-10 max-h-10 bg-surface-2 text-text-1 border-l-0",
54
+ "border-border bg-background text-foreground min-h-10 max-h-10 rounded-l-none rounded-r-xl border-l-0",
55
55
  className,
56
56
  )}
57
57
  aria-label={placeholder}
@@ -41,7 +41,7 @@ export function DatePicker({
41
41
  ) : null}
42
42
  <Popover>
43
43
  <div className="flex w-full items-center">
44
- <div className="border-border bg-surface-2 text-text-1 flex size-10 shrink-0 items-center justify-center rounded-l-xl border">
44
+ <div className="border-border bg-background text-foreground flex size-10 shrink-0 items-center justify-center rounded-l-xl border">
45
45
  <CalendarIcon className="size-4" aria-hidden />
46
46
  </div>
47
47
  <PopoverTrigger asChild>
@@ -49,7 +49,7 @@ export function DatePicker({
49
49
  id={id}
50
50
  variant={variant}
51
51
  disabled={disabled}
52
- className="border-border bg-surface-2 text-text-1 placeholder:text-muted-foreground min-h-10 max-h-10 w-full justify-start rounded-l-none rounded-r-xl border-l-0 text-left font-normal"
52
+ className="border-border bg-background text-foreground placeholder:text-muted-foreground min-h-10 max-h-10 w-full justify-start rounded-l-none rounded-r-xl border-l-0 text-left font-normal"
53
53
  >
54
54
  {value ? format(value, dateFormat) : <span>{placeholder}</span>}
55
55
  </Button>
@@ -1,56 +1,140 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { useState } from "react";
3
+ import { expect, fireEvent, userEvent, within } from "storybook/test";
3
4
 
4
- import { DropZone } from "./drop-zone";
5
+ import { DropZone, type DropZoneFileInfo, type DropZoneStatus } from "./drop-zone";
5
6
 
6
7
  const meta = {
7
8
  title: "Forms/DropZone",
8
9
  component: DropZone,
9
10
  tags: ["autodocs"],
11
+ args: {
12
+ status: "empty",
13
+ onFiles: () => {},
14
+ },
10
15
  } satisfies Meta<typeof DropZone>;
11
16
 
12
17
  export default meta;
13
18
  type Story = StoryObj<typeof meta>;
14
19
 
15
- function DropZonePreview({ isImporting = false }: { isImporting?: boolean }) {
16
- const [dragActive, setDragActive] = useState(false);
17
- const [files, setFiles] = useState<File[]>([]);
20
+ function DropZonePreview({ initialStatus = "empty" }: { initialStatus?: DropZoneStatus }) {
21
+ const [status, setStatus] = useState<DropZoneStatus>(initialStatus);
22
+ const [file, setFile] = useState<DropZoneFileInfo | null>(
23
+ initialStatus === "empty" ? null : { name: "applications.csv", size: 24_576 },
24
+ );
18
25
 
19
26
  return (
20
- <div className="w-96 space-y-3">
27
+ <div className="w-96">
21
28
  <DropZone
22
- isImporting={isImporting}
23
- dragActive={dragActive}
24
- onDragOver={() => setDragActive(true)}
25
- onDragLeave={() => setDragActive(false)}
26
- onDrop={setFiles}
27
- onChange={setFiles}
29
+ status={status}
30
+ file={file}
28
31
  accept=".csv"
29
- helpText="Supported formats: CSV"
32
+ emptyHint="Supported formats: CSV"
33
+ error={status === "failed" ? "The file could not be processed." : undefined}
34
+ busyDescription="This usually takes a moment."
35
+ onFiles={(files) => {
36
+ const selectedFile = files[0];
37
+ if (!selectedFile) return;
38
+ setFile({ name: selectedFile.name, size: selectedFile.size });
39
+ setStatus("armed");
40
+ }}
41
+ onSubmit={() => setStatus("busy")}
42
+ onCancel={() => setStatus("armed")}
43
+ onRetry={() => setStatus("busy")}
44
+ onClear={() => {
45
+ setFile(null);
46
+ setStatus("empty");
47
+ }}
30
48
  />
31
- {files.length > 0 ? (
32
- <p className="text-muted-foreground text-sm">
33
- {files.length} file{files.length > 1 ? "s" : ""} selected
34
- </p>
35
- ) : null}
36
49
  </div>
37
50
  );
38
51
  }
39
52
 
40
- const baseArgs = {
41
- isImporting: false,
42
- dragActive: false,
43
- onDrop: () => {},
44
- onDragOver: () => {},
45
- onDragLeave: () => {},
53
+ export const Default: Story = {
54
+ render: () => <DropZonePreview />,
55
+ play: async ({ canvasElement }) => {
56
+ const canvas = within(canvasElement);
57
+ const input = canvasElement.querySelector('input[type="file"]') as HTMLInputElement;
58
+ const file = new File(["name,email\nAda,ada@example.com"], "applications.csv", {
59
+ type: "text/csv",
60
+ });
61
+
62
+ await expect(canvas.getByText("Drop a file here, or")).toBeInTheDocument();
63
+ await fireEvent.change(input, { target: { files: [file] } });
64
+ await expect(canvas.getByText("applications.csv")).toBeInTheDocument();
65
+ await expect(canvas.getByRole("button", { name: /Upload/ })).toBeInTheDocument();
66
+
67
+ await userEvent.click(canvas.getByRole("button", { name: /Upload/ }));
68
+ await expect(canvas.getByRole("progressbar", { name: "Processing…" })).toBeInTheDocument();
69
+ await expect(canvas.getByRole("button", { name: /Cancel/ })).toBeInTheDocument();
70
+ },
46
71
  };
47
72
 
48
- export const Default: Story = {
49
- args: baseArgs,
73
+ export const Armed: Story = {
74
+ render: () => <DropZonePreview initialStatus="armed" />,
75
+ play: async ({ canvasElement }) => {
76
+ const canvas = within(canvasElement);
77
+
78
+ await expect(canvas.getByText("applications.csv")).toBeInTheDocument();
79
+ await expect(canvas.getByText("24.0 KB")).toBeInTheDocument();
80
+ await userEvent.click(canvas.getByRole("button", { name: /Use a different file/ }));
81
+ await expect(canvas.getByText("Drop a file here, or")).toBeInTheDocument();
82
+ },
83
+ };
84
+
85
+ export const Busy: Story = {
86
+ render: () => <DropZonePreview initialStatus="busy" />,
87
+ play: async ({ canvasElement }) => {
88
+ const canvas = within(canvasElement);
89
+
90
+ await expect(canvas.getByRole("progressbar", { name: "Processing…" })).toBeInTheDocument();
91
+ await expect(canvas.getByRole("button", { name: /Cancel/ })).toBeInTheDocument();
92
+ await expect(canvas.queryByRole("button", { name: /Upload/ })).not.toBeInTheDocument();
93
+ await userEvent.click(canvas.getByRole("button", { name: /Cancel/ }));
94
+ await expect(canvas.getByRole("button", { name: /Upload/ })).toBeInTheDocument();
95
+ },
96
+ };
97
+
98
+ export const Dragging: Story = {
50
99
  render: () => <DropZonePreview />,
100
+ play: async ({ canvasElement }) => {
101
+ const canvas = within(canvasElement);
102
+ const zone = canvas.getByRole("button", { name: "File upload drop zone" });
103
+ const file = new File(["data"], "applications.csv", { type: "text/csv" });
104
+
105
+ await fireEvent.dragEnter(zone);
106
+ await expect(canvas.getByText("Release to attach")).toBeInTheDocument();
107
+ await fireEvent.dragLeave(zone);
108
+ await expect(canvas.getByText("Drop a file here, or")).toBeInTheDocument();
109
+ const dataTransfer = new DataTransfer();
110
+ dataTransfer.items.add(file);
111
+ Object.defineProperty(dataTransfer, "files", { value: [file] });
112
+ await fireEvent.drop(zone, { dataTransfer });
113
+ await expect(canvas.getByText("applications.csv")).toBeInTheDocument();
114
+ },
51
115
  };
52
116
 
53
- export const Importing: Story = {
54
- args: { ...baseArgs, isImporting: true },
55
- render: () => <DropZonePreview isImporting />,
117
+ export const Failed: Story = {
118
+ render: () => <DropZonePreview initialStatus="failed" />,
119
+ play: async ({ canvasElement }) => {
120
+ const canvas = within(canvasElement);
121
+
122
+ await expect(canvas.getByText("The file could not be processed.")).toBeInTheDocument();
123
+ await expect(canvas.getByRole("button", { name: /Try again/ })).toBeInTheDocument();
124
+ await userEvent.click(canvas.getByRole("button", { name: /Try again/ }));
125
+ await expect(canvas.getByRole("progressbar", { name: "Processing…" })).toBeInTheDocument();
126
+ },
127
+ };
128
+
129
+ export const Disabled: Story = {
130
+ args: { status: "empty", disabled: true },
131
+ play: async ({ canvasElement }) => {
132
+ const canvas = within(canvasElement);
133
+ const zone = canvas.getByRole("button", { name: "File upload drop zone" });
134
+ const input = canvasElement.querySelector('input[type="file"]') as HTMLInputElement;
135
+
136
+ await expect(zone).toHaveAttribute("aria-disabled", "true");
137
+ await expect(zone).toHaveAttribute("tabindex", "-1");
138
+ await expect(input).toBeDisabled();
139
+ },
56
140
  };