@ponti-studios/ui 1.1.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 (48) 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 +7 -3
  9. package/src/components/filters/active-filters-bar.stories.tsx +36 -36
  10. package/src/components/filters/active-filters-bar.tsx +21 -23
  11. package/src/components/filters/filter-select.stories.tsx +26 -26
  12. package/src/components/filters/filter-select.tsx +5 -8
  13. package/src/components/filters/index.ts +0 -4
  14. package/src/components/forms/date-month-select.tsx +2 -2
  15. package/src/components/forms/date-picker.tsx +2 -2
  16. package/src/components/forms/drop-zone.stories.tsx +112 -28
  17. package/src/components/forms/drop-zone.tsx +265 -112
  18. package/src/components/forms/input.tsx +5 -1
  19. package/src/components/forms/radio-group.tsx +3 -2
  20. package/src/components/forms/select.tsx +14 -4
  21. package/src/components/forms/slider.stories.tsx +2 -2
  22. package/src/components/forms/slider.tsx +12 -9
  23. package/src/components/forms/switch.tsx +5 -2
  24. package/src/components/forms/textarea.tsx +5 -1
  25. package/src/components/layout/index.ts +0 -2
  26. package/src/components/layout/section-intro.tsx +1 -1
  27. package/src/components/layout/surface-panel.tsx +10 -2
  28. package/src/components/navigation/app-navigation.stories.tsx +5 -1
  29. package/src/components/navigation/app-navigation.tsx +19 -4
  30. package/src/components/navigation/tabs.tsx +4 -1
  31. package/src/components/overlays/command.tsx +39 -14
  32. package/src/components/overlays/popover.stories.tsx +16 -4
  33. package/src/components/primitives/badge.tsx +1 -1
  34. package/src/components/primitives/button.tsx +6 -1
  35. package/src/hooks/use-derived-filter-state.ts +1 -3
  36. package/src/hooks/use-filter-state.ts +1 -2
  37. package/src/native/card.tsx +3 -1
  38. package/src/native/index.ts +21 -11
  39. package/src/native/list-row.tsx +67 -0
  40. package/src/styles/tokens/generated/native.generated.ts +34 -6
  41. package/src/styles/tokens/index.ts +2 -0
  42. package/src/styles/tokens/normalize-native.ts +5 -3
  43. package/src/styles/tokens/source/native.tokens.json +42 -7
  44. package/src/styles/tokens/style-dictionary.native.mjs +4 -1
  45. package/src/components/filters/filters.stories.tsx +0 -72
  46. package/src/components/filters/filters.tsx +0 -78
  47. package/src/components/filters/search-filter-bar.stories.tsx +0 -41
  48. package/src/components/filters/search-filter-bar.tsx +0 -48
@@ -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
  };
@@ -1,146 +1,299 @@
1
- import type React from "react";
2
- import { useCallback, useId } from "react";
1
+ import {
2
+ useCallback,
3
+ useId,
4
+ useRef,
5
+ useState,
6
+ type ChangeEvent,
7
+ type DragEvent,
8
+ type KeyboardEvent,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { FileIcon, UploadIcon, XIcon } from "lucide-react";
3
12
 
4
13
  import { cn } from "../../lib/utils";
14
+ import { Button } from "../primitives/button";
5
15
 
6
- export interface DropZoneProps {
7
- isImporting: boolean;
8
- dragActive: boolean;
9
- onDrop: (files: File[]) => void;
10
- onDragOver: () => void;
11
- onDragLeave: () => void;
12
- onChange?: (files: File[]) => void;
16
+ export type DropZoneStatus = "empty" | "armed" | "busy" | "failed";
17
+
18
+ export type DropZoneFileInfo = {
19
+ name: string;
20
+ size: number;
21
+ };
22
+
23
+ export type DropZoneProps = {
24
+ status: DropZoneStatus;
25
+ /** Selected file metadata for armed / busy / failed (with file retained). */
26
+ file?: DropZoneFileInfo | null;
27
+ error?: string | null;
28
+ notice?: string | null;
29
+ /** File input accept attribute, e.g. `.pdf,application/pdf`. */
13
30
  accept?: string;
14
31
  multiple?: boolean;
32
+ disabled?: boolean;
15
33
  className?: string;
16
- label?: string;
17
- helpText?: string;
34
+ /** Empty idle copy */
35
+ emptyLabel?: ReactNode;
36
+ emptyHint?: ReactNode;
37
+ draggingLabel?: ReactNode;
38
+ /** Busy copy */
39
+ busyLabel?: ReactNode;
40
+ busyDescription?: ReactNode;
41
+ /** Action labels — parent owns domain language */
42
+ submitLabel?: string;
43
+ retryLabel?: string;
44
+ clearLabel?: string;
45
+ cancelLabel?: string;
46
+ /** Extra content under error (e.g. sign-in link). */
47
+ failedActions?: ReactNode;
48
+ onFiles: (files: File[]) => void;
49
+ onSubmit?: () => void;
50
+ onClear?: () => void;
51
+ onCancel?: () => void;
52
+ onRetry?: () => void;
53
+ };
54
+
55
+ function formatFileSize(bytes: number) {
56
+ if (bytes < 1024) return `${bytes} B`;
57
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
58
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
18
59
  }
19
60
 
20
- /** Drag-and-drop file picker with a click-to-browse fallback. */
61
+ /**
62
+ * Generic file drop surface. Parent owns validation, network, and domain copy.
63
+ * Modes: empty | armed | busy | failed, plus an internal drag overlay.
64
+ */
21
65
  export function DropZone({
22
- isImporting,
23
- dragActive,
24
- onDrop,
25
- onDragOver,
26
- onDragLeave,
27
- onChange,
66
+ status,
67
+ file = null,
68
+ error = null,
69
+ notice = null,
28
70
  accept,
29
- multiple = true,
71
+ multiple = false,
72
+ disabled = false,
30
73
  className,
31
- label = "Drag and drop files here, or",
32
- helpText,
74
+ emptyLabel = (
75
+ <>
76
+ Drop a file here, or <span className="text-primary font-medium">browse</span>
77
+ </>
78
+ ),
79
+ emptyHint,
80
+ draggingLabel = "Release to attach",
81
+ busyLabel = "Processing…",
82
+ busyDescription,
83
+ submitLabel = "Upload",
84
+ retryLabel = "Try again",
85
+ clearLabel = "Use a different file",
86
+ cancelLabel = "Cancel",
87
+ failedActions,
88
+ onFiles,
89
+ onSubmit,
90
+ onClear,
91
+ onCancel,
92
+ onRetry,
33
93
  }: DropZoneProps) {
34
94
  const inputId = useId();
95
+ const inputRef = useRef<HTMLInputElement>(null);
96
+ const zoneRef = useRef<HTMLDivElement>(null);
97
+ const [dragging, setDragging] = useState(false);
35
98
 
36
- const handleDrop = useCallback(
37
- (e: React.DragEvent<HTMLButtonElement>) => {
38
- e.preventDefault();
39
- e.stopPropagation();
40
- onDragLeave();
99
+ const isBusy = status === "busy";
100
+ const isInteractive = !disabled && !isBusy;
41
101
 
42
- if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
43
- onDrop(Array.from(e.dataTransfer.files));
44
- }
102
+ const openPicker = useCallback(() => {
103
+ if (!isInteractive) return;
104
+ inputRef.current?.click();
105
+ }, [isInteractive]);
106
+
107
+ const handleFiles = useCallback(
108
+ (list: FileList | File[] | null) => {
109
+ if (!list || !isInteractive) return;
110
+ const files = Array.from(list);
111
+ if (files.length === 0) return;
112
+ onFiles(files);
45
113
  },
46
- [onDrop, onDragLeave],
114
+ [isInteractive, onFiles],
47
115
  );
48
116
 
49
- const triggerFileInput = useCallback(() => {
50
- if (!isImporting) {
51
- document.getElementById(inputId)?.click();
52
- }
53
- }, [isImporting, inputId]);
117
+ const onDragEnter = (event: DragEvent) => {
118
+ event.preventDefault();
119
+ event.stopPropagation();
120
+ if (!isInteractive) return;
121
+ setDragging(true);
122
+ };
54
123
 
55
- const handleDragOver = useCallback(
56
- (e: React.DragEvent<HTMLButtonElement>) => {
57
- e.preventDefault();
58
- e.stopPropagation();
59
- onDragOver();
60
- },
61
- [onDragOver],
62
- );
124
+ const onDragOver = (event: DragEvent) => {
125
+ event.preventDefault();
126
+ event.stopPropagation();
127
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
128
+ };
63
129
 
64
- const handleDragLeave = useCallback(
65
- (e: React.DragEvent<HTMLButtonElement>) => {
66
- e.preventDefault();
67
- e.stopPropagation();
68
- onDragLeave();
69
- },
70
- [onDragLeave],
71
- );
130
+ const onDragLeave = (event: DragEvent) => {
131
+ event.preventDefault();
132
+ event.stopPropagation();
133
+ const next = event.relatedTarget;
134
+ if (next instanceof Node && zoneRef.current?.contains(next)) return;
135
+ setDragging(false);
136
+ };
72
137
 
73
- const handleClick = useCallback(
74
- (e: React.MouseEvent<HTMLButtonElement>) => {
75
- e.stopPropagation();
76
- triggerFileInput();
77
- },
78
- [triggerFileInput],
79
- );
138
+ const onDrop = (event: DragEvent) => {
139
+ event.preventDefault();
140
+ event.stopPropagation();
141
+ setDragging(false);
142
+ if (!isInteractive) return;
143
+ handleFiles(event.dataTransfer.files);
144
+ };
80
145
 
81
- const handleKeyDown = useCallback(
82
- (e: React.KeyboardEvent<HTMLButtonElement>) => {
83
- if (e.key === "Enter" || e.key === " ") {
84
- e.preventDefault();
85
- e.stopPropagation();
86
- triggerFileInput();
87
- }
88
- },
89
- [triggerFileInput],
90
- );
146
+ const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
147
+ handleFiles(event.target.files);
148
+ event.target.value = "";
149
+ };
91
150
 
92
- const handleInputChange = useCallback(
93
- (e: React.ChangeEvent<HTMLInputElement>) => {
94
- e.stopPropagation();
95
- if (onChange) {
96
- onChange(e.target.files ? Array.from(e.target.files) : []);
97
- }
98
- },
99
- [onChange],
151
+ const onKeyDown = (event: KeyboardEvent) => {
152
+ if (event.key === "Enter" || event.key === " ") {
153
+ event.preventDefault();
154
+ openPicker();
155
+ }
156
+ };
157
+
158
+ const showDragging = dragging && isInteractive;
159
+ const frameTone = cn(
160
+ showDragging && "border-primary bg-primary/5",
161
+ status === "failed" && !showDragging && "border-destructive/50 bg-destructive/5",
162
+ status === "busy" && "border-border bg-muted/30",
163
+ status !== "failed" && status !== "busy" && !showDragging && "border-border bg-card",
100
164
  );
101
165
 
102
166
  return (
103
- <div>
104
- <button
105
- type="button"
106
- className={cn(
107
- "relative border-2 border-dashed p-12",
108
- {
109
- "border-primary bg-primary/5": dragActive,
110
- "border-border bg-muted": !dragActive,
111
- },
112
- "flex flex-col items-center justify-center gap-4",
113
- {
114
- "pointer-events-none cursor-not-allowed": isImporting,
115
- },
116
- className,
117
- )}
118
- onDragOver={handleDragOver}
119
- onDragLeave={handleDragLeave}
120
- onDrop={handleDrop}
121
- onClick={handleClick}
122
- onKeyDown={handleKeyDown}
123
- tabIndex={isImporting ? -1 : 0}
124
- aria-disabled={isImporting}
125
- aria-label="Upload file area. Press Enter or Space to open file browser"
126
- >
127
- <div className="space-y-2 text-center">
128
- <p className="text-muted-foreground">
129
- {label} <span className="text-accent font-medium">click to browse</span>
130
- </p>
131
- {helpText ? <p className="text-muted-foreground text-sm">{helpText}</p> : null}
132
- </div>
133
- </button>
167
+ <div className={cn("w-full space-y-3", className)}>
134
168
  <input
169
+ ref={inputRef}
135
170
  id={inputId}
136
171
  type="file"
137
- multiple={multiple}
138
172
  accept={accept}
139
- onChange={handleInputChange}
140
- className="sr-only"
141
- disabled={isImporting}
142
- aria-label="File input"
173
+ multiple={multiple}
174
+ disabled={!isInteractive}
175
+ className="pointer-events-none sr-only"
176
+ onChange={onInputChange}
177
+ tabIndex={-1}
178
+ aria-hidden
143
179
  />
180
+
181
+ <div
182
+ ref={zoneRef}
183
+ role="button"
184
+ tabIndex={isInteractive ? 0 : -1}
185
+ aria-disabled={!isInteractive}
186
+ aria-busy={isBusy}
187
+ aria-label="File upload drop zone"
188
+ className={cn(
189
+ "relative flex min-h-44 w-full flex-col items-center justify-center overflow-hidden rounded-2xl border border-dashed p-6 text-center outline-none",
190
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2",
191
+ "motion-safe:transition-[transform,opacity] motion-safe:duration-200 motion-safe:ease-in-out motion-reduce:transition-none",
192
+ isInteractive ? "cursor-pointer" : "cursor-default",
193
+ showDragging ? "scale-[1.01] motion-reduce:scale-100" : "scale-100",
194
+ frameTone,
195
+ )}
196
+ onClick={isInteractive ? openPicker : undefined}
197
+ onKeyDown={isInteractive ? onKeyDown : undefined}
198
+ onDragEnter={onDragEnter}
199
+ onDragOver={onDragOver}
200
+ onDragLeave={onDragLeave}
201
+ onDrop={onDrop}
202
+ >
203
+ <div className="flex w-full max-w-sm flex-col items-center gap-3">
204
+ {showDragging ? (
205
+ <>
206
+ <UploadIcon
207
+ className="text-primary size-8 motion-safe:transition-[transform,opacity] motion-safe:duration-200 motion-safe:ease-in-out motion-reduce:transition-none motion-safe:-translate-y-0.5 motion-reduce:translate-y-0"
208
+ aria-hidden
209
+ />
210
+ <p className="body-3 text-foreground">{draggingLabel}</p>
211
+ </>
212
+ ) : null}
213
+
214
+ {!showDragging && status === "empty" ? (
215
+ <>
216
+ <UploadIcon className="size-8 text-muted-foreground" aria-hidden />
217
+ <div className="space-y-1">
218
+ <p className="body-3 text-foreground">{emptyLabel}</p>
219
+ {emptyHint ? <p className="body-4 text-muted-foreground">{emptyHint}</p> : null}
220
+ </div>
221
+ </>
222
+ ) : null}
223
+
224
+ {!showDragging && status === "armed" && file ? (
225
+ <>
226
+ <FileIcon className="size-8 text-muted-foreground" aria-hidden />
227
+ <div className="min-w-0 max-w-full space-y-1">
228
+ <p className="subheading-4 truncate text-foreground">{file.name}</p>
229
+ <p className="body-4 text-muted-foreground">{formatFileSize(file.size)}</p>
230
+ </div>
231
+ </>
232
+ ) : null}
233
+
234
+ {!showDragging && status === "busy" ? (
235
+ <div className="w-full space-y-3">
236
+ <div className="space-y-1">
237
+ <p className="subheading-4 text-foreground">{busyLabel}</p>
238
+ {busyDescription ? (
239
+ <p className="body-4 text-muted-foreground">{busyDescription}</p>
240
+ ) : null}
241
+ {file ? <p className="body-4 truncate text-muted-foreground">{file.name}</p> : null}
242
+ </div>
243
+ <div
244
+ className="bg-muted h-1.5 w-full overflow-hidden rounded-full"
245
+ role="progressbar"
246
+ aria-label={typeof busyLabel === "string" ? busyLabel : "Processing"}
247
+ >
248
+ <div className="bg-primary h-full w-2/5 rounded-full opacity-80 motion-safe:animate-pulse" />
249
+ </div>
250
+ </div>
251
+ ) : null}
252
+
253
+ {!showDragging && status === "failed" ? (
254
+ <div className="w-full space-y-2">
255
+ {file ? <p className="subheading-4 truncate text-foreground">{file.name}</p> : null}
256
+ {error ? <p className="body-3 text-destructive">{error}</p> : null}
257
+ {failedActions}
258
+ </div>
259
+ ) : null}
260
+ </div>
261
+ </div>
262
+
263
+ {notice ? <p className="body-4 text-muted-foreground">{notice}</p> : null}
264
+
265
+ <div className="grid gap-2">
266
+ {status === "armed" && onSubmit ? (
267
+ <Button type="button" className="w-full" onClick={onSubmit}>
268
+ <UploadIcon className="size-4" />
269
+ {submitLabel}
270
+ </Button>
271
+ ) : null}
272
+ {status === "failed" && onRetry ? (
273
+ <Button type="button" className="w-full" onClick={onRetry}>
274
+ {retryLabel}
275
+ </Button>
276
+ ) : null}
277
+ {status === "busy" && onCancel ? (
278
+ <Button type="button" variant="outline" className="w-full" onClick={onCancel}>
279
+ <XIcon className="size-4" />
280
+ {cancelLabel}
281
+ </Button>
282
+ ) : null}
283
+ {(status === "armed" || status === "failed") && onClear ? (
284
+ <Button
285
+ type="button"
286
+ variant="ghost"
287
+ className="w-full"
288
+ onClick={(event) => {
289
+ event.stopPropagation();
290
+ onClear();
291
+ }}
292
+ >
293
+ {clearLabel}
294
+ </Button>
295
+ ) : null}
296
+ </div>
144
297
  </div>
145
298
  );
146
299
  }
@@ -8,7 +8,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background text-foreground focus-visible:ring-ring border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-colors file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ "bg-background text-foreground border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-colors md:text-sm",
12
+ "file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
13
+ "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
14
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
15
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
12
16
  "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
13
17
  className,
14
18
  )}
@@ -23,8 +23,9 @@ const RadioGroupItem = React.forwardRef<
23
23
  <RadioPrimitive.Root
24
24
  ref={ref}
25
25
  className={cn(
26
- "border-primary bg-background focus-visible:border-ring focus-visible:bg-muted/25 focus-visible:ring-ring aspect-square size-6 rounded-full border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
27
- "flex items-center justify-center",
26
+ "border-primary bg-background flex aspect-square size-6 items-center justify-center rounded-full border",
27
+ "focus-visible:border-ring focus-visible:bg-muted/25 focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
28
+ "disabled:cursor-not-allowed disabled:opacity-50",
28
29
  className,
29
30
  )}
30
31
  {...props}
@@ -44,7 +44,14 @@ function SelectTrigger({
44
44
  data-slot="select-trigger"
45
45
  data-size={size}
46
46
  className={cn(
47
- "data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive border-input flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
47
+ "border-input flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none",
48
+ "data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
49
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:ring-inset",
50
+ "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
51
+ "disabled:cursor-not-allowed disabled:opacity-50",
52
+ "data-[size=default]:h-9 data-[size=sm]:h-8",
53
+ "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
54
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
48
55
  className,
49
56
  )}
50
57
  {...props}
@@ -60,18 +67,21 @@ function SelectTrigger({
60
67
  function SelectContent({
61
68
  className,
62
69
  children,
63
- position: _position = "item-aligned",
70
+ position: _position = "popper",
64
71
  ...props
65
72
  }: React.ComponentProps<typeof SelectPrimitive.Popup> & {
66
73
  position?: "item-aligned" | "popper";
67
74
  }) {
68
75
  return (
69
76
  <SelectPrimitive.Portal>
70
- <SelectPrimitive.Positioner sideOffset={4}>
77
+ <SelectPrimitive.Positioner
78
+ alignItemWithTrigger={_position === "item-aligned"}
79
+ sideOffset={4}
80
+ >
71
81
  <SelectPrimitive.Popup
72
82
  data-slot="select-content"
73
83
  className={cn(
74
- "bg-background text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:zoom-out-95 data-open:zoom-in-95 relative z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 outline-none",
84
+ "bg-background text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:zoom-out-95 data-open:zoom-in-95 relative z-50 max-h-(--available-height) min-w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 outline-none",
75
85
  className,
76
86
  )}
77
87
  {...props}
@@ -9,7 +9,7 @@ const meta: Meta<typeof Slider> = {
9
9
  tags: ["autodocs"],
10
10
  decorators: [
11
11
  (Story) => (
12
- <div className="w-full max-w-sm px-1">
12
+ <div className="w-80 px-1">
13
13
  <Story />
14
14
  </div>
15
15
  ),
@@ -48,7 +48,7 @@ export const Controlled: Story = {
48
48
  render: () => {
49
49
  const [value, setValue] = React.useState([33]);
50
50
  return (
51
- <div className="w-full max-w-sm space-y-4 px-1">
51
+ <div className="w-80 space-y-4 px-1">
52
52
  <Slider aria-label="Volume" value={value} onValueChange={setValue} min={0} max={100} />
53
53
  <div className="flex justify-between text-sm">
54
54
  <span className="text-muted-foreground">Volume</span>