@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.
- package/package.json +1 -1
- package/src/components/data-display/accordion.tsx +10 -1
- package/src/components/feedback/empty-state.tsx +1 -1
- package/src/components/feedback/file-upload-status.tsx +14 -6
- package/src/components/feedback/progress-bar.tsx +3 -2
- package/src/components/feedback/spinner.tsx +3 -4
- package/src/components/feedback/update-guard.stories.tsx +1 -1
- package/src/components/feedback/update-guard.tsx +7 -3
- package/src/components/filters/active-filters-bar.stories.tsx +36 -36
- package/src/components/filters/active-filters-bar.tsx +21 -23
- package/src/components/filters/filter-select.stories.tsx +26 -26
- package/src/components/filters/filter-select.tsx +5 -8
- package/src/components/filters/index.ts +0 -4
- package/src/components/forms/date-month-select.tsx +2 -2
- package/src/components/forms/date-picker.tsx +2 -2
- package/src/components/forms/drop-zone.stories.tsx +112 -28
- package/src/components/forms/drop-zone.tsx +265 -112
- package/src/components/forms/input.tsx +5 -1
- package/src/components/forms/radio-group.tsx +3 -2
- package/src/components/forms/select.tsx +14 -4
- package/src/components/forms/slider.stories.tsx +2 -2
- package/src/components/forms/slider.tsx +12 -9
- package/src/components/forms/switch.tsx +5 -2
- package/src/components/forms/textarea.tsx +5 -1
- package/src/components/layout/index.ts +0 -2
- package/src/components/layout/section-intro.tsx +1 -1
- package/src/components/layout/surface-panel.tsx +10 -2
- package/src/components/navigation/app-navigation.stories.tsx +5 -1
- package/src/components/navigation/app-navigation.tsx +19 -4
- package/src/components/navigation/tabs.tsx +4 -1
- package/src/components/overlays/command.tsx +39 -14
- package/src/components/overlays/popover.stories.tsx +16 -4
- package/src/components/primitives/badge.tsx +1 -1
- package/src/components/primitives/button.tsx +6 -1
- package/src/hooks/use-derived-filter-state.ts +1 -3
- package/src/hooks/use-filter-state.ts +1 -2
- package/src/native/card.tsx +3 -1
- package/src/native/index.ts +21 -11
- package/src/native/list-row.tsx +67 -0
- package/src/styles/tokens/generated/native.generated.ts +34 -6
- package/src/styles/tokens/index.ts +2 -0
- package/src/styles/tokens/normalize-native.ts +5 -3
- package/src/styles/tokens/source/native.tokens.json +42 -7
- package/src/styles/tokens/style-dictionary.native.mjs +4 -1
- package/src/components/filters/filters.stories.tsx +0 -72
- package/src/components/filters/filters.tsx +0 -78
- package/src/components/filters/search-filter-bar.stories.tsx +0 -41
- 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({
|
|
16
|
-
const [
|
|
17
|
-
const [
|
|
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
|
|
27
|
+
<div className="w-96">
|
|
21
28
|
<DropZone
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
2
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
onDragLeave,
|
|
27
|
-
onChange,
|
|
66
|
+
status,
|
|
67
|
+
file = null,
|
|
68
|
+
error = null,
|
|
69
|
+
notice = null,
|
|
28
70
|
accept,
|
|
29
|
-
multiple =
|
|
71
|
+
multiple = false,
|
|
72
|
+
disabled = false,
|
|
30
73
|
className,
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
e.preventDefault();
|
|
39
|
-
e.stopPropagation();
|
|
40
|
-
onDragLeave();
|
|
99
|
+
const isBusy = status === "busy";
|
|
100
|
+
const isInteractive = !disabled && !isBusy;
|
|
41
101
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
[
|
|
114
|
+
[isInteractive, onFiles],
|
|
47
115
|
);
|
|
48
116
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
117
|
+
const onDragEnter = (event: DragEvent) => {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
if (!isInteractive) return;
|
|
121
|
+
setDragging(true);
|
|
122
|
+
};
|
|
54
123
|
|
|
55
|
-
const
|
|
56
|
-
(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
65
|
-
(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
74
|
-
(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
82
|
-
(
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
93
|
-
(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
"
|
|
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
|
|
27
|
-
"
|
|
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
|
-
"
|
|
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 = "
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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>
|