aplance-ui 1.0.1
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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/api.d.ts +175 -0
- package/dist/api.js +1954 -0
- package/dist/api.js.map +1 -0
- package/dist/frameworks/astro/astro.config.snippet.md +23 -0
- package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
- package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
- package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
- package/dist/frameworks/next/layout-import.snippet.md +19 -0
- package/dist/frameworks/next/next.config.snippet.md +15 -0
- package/dist/frameworks/next/postcss.config.js.template +16 -0
- package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
- package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
- package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
- package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
- package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/registry/components/primitives/icon/icon.module.css +63 -0
- package/registry/components/primitives/icon/icon.tsx +53 -0
- package/registry/components/primitives/modal/modal-context.ts +19 -0
- package/registry/components/primitives/modal/modal.module.css +415 -0
- package/registry/components/primitives/modal/modal.tsx +550 -0
- package/registry/components/primitives/popover/popover-context.ts +28 -0
- package/registry/components/primitives/popover/popover.module.css +28 -0
- package/registry/components/primitives/popover/popover.tsx +465 -0
- package/registry/components/ui/accordion/accordion.module.css +208 -0
- package/registry/components/ui/accordion/accordion.tsx +327 -0
- package/registry/components/ui/avatar/avatar.module.css +32 -0
- package/registry/components/ui/avatar/avatar.tsx +37 -0
- package/registry/components/ui/badge/badge.module.css +103 -0
- package/registry/components/ui/badge/badge.tsx +29 -0
- package/registry/components/ui/button/button.module.css +216 -0
- package/registry/components/ui/button/button.tsx +133 -0
- package/registry/components/ui/chart/chart.module.css +168 -0
- package/registry/components/ui/chart/chart.tsx +1293 -0
- package/registry/components/ui/chart/types.ts +210 -0
- package/registry/components/ui/chart/utils.ts +59 -0
- package/registry/components/ui/copy-button/copy-button.module.css +21 -0
- package/registry/components/ui/copy-button/copy-button.tsx +123 -0
- package/registry/components/ui/countdown/countdown.module.css +5 -0
- package/registry/components/ui/countdown/countdown.tsx +152 -0
- package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
- package/registry/components/ui/data-table/data-table.module.css +594 -0
- package/registry/components/ui/data-table/data-table.tsx +1109 -0
- package/registry/components/ui/data-table/filter-bar.tsx +33 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
- package/registry/components/ui/data-table/types.ts +594 -0
- package/registry/components/ui/data-table/useDataTable.tsx +573 -0
- package/registry/components/ui/data-table/utils.ts +445 -0
- package/registry/components/ui/empty-state/empty-state.module.css +26 -0
- package/registry/components/ui/empty-state/empty-state.tsx +32 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
- package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
- package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
- package/registry/components/ui/input-fields/common/types.ts +38 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
- package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
- package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
- package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
- package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
- package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
- package/registry/components/ui/input-fields/input/input.tsx +319 -0
- package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
- package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
- package/registry/components/ui/input-fields/media-input/types.ts +129 -0
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
- package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
- package/registry/components/ui/input-fields/select/select.tsx +118 -0
- package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
- package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
- package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
- package/registry/components/ui/loader/loader.module.css +156 -0
- package/registry/components/ui/loader/loader.tsx +102 -0
- package/registry/components/ui/stepper/stepper.module.css +294 -0
- package/registry/components/ui/stepper/stepper.tsx +197 -0
- package/registry/components/ui/tabs/tabs.module.css +150 -0
- package/registry/components/ui/tabs/tabs.tsx +134 -0
- package/registry/components/ui/toast/toast.module.css +159 -0
- package/registry/components/ui/toast/toast.tsx +104 -0
- package/registry/components/ui/tooltip/tooltip.module.css +82 -0
- package/registry/components/ui/tooltip/tooltip.tsx +72 -0
- package/registry/global.d.ts +4 -0
- package/registry/hooks/useFocusOutside.tsx +33 -0
- package/registry/hooks/useResizeObserver.tsx +32 -0
- package/registry/lib/dom.ts +25 -0
- package/registry/lib/string.ts +23 -0
- package/registry/registry.json +719 -0
- package/registry/styles/app.css +240 -0
- package/registry/styles/components.css +78 -0
- package/registry/styles/fonts/geist-regular.woff2 +0 -0
- package/registry/styles/globals.css +7 -0
- package/registry/styles/media.css +25 -0
- package/registry/styles/utilities.css +90 -0
- package/registry/themes/colors/blue.css +37 -0
- package/registry/themes/colors/green.css +37 -0
- package/registry/themes/colors/rose.css +37 -0
- package/registry/themes/colors/slate.css +37 -0
- package/registry/themes/colors/stone.css +37 -0
- package/registry/themes/colors/violet.css +37 -0
- package/registry/themes/colors/zinc.css +37 -0
- package/registry/themes/radius/pill.css +8 -0
- package/registry/themes/radius/rounded.css +8 -0
- package/registry/themes/radius/sharp.css +8 -0
- package/registry/themes/spacing/compact.css +17 -0
- package/registry/themes/spacing/spacious.css +17 -0
- package/registry/tsconfig.json +25 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { type MouseEvent, useId, useMemo } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { IconUpload, IconX } from "@tabler/icons-react";
|
|
4
|
+
import sCommon from "../common/input-field-common.module.css";
|
|
5
|
+
import sButton from "../../button/button.module.css";
|
|
6
|
+
import { FieldMessage } from "../common/field-message/field-message";
|
|
7
|
+
import { Icon } from "../../../primitives/icon/icon";
|
|
8
|
+
import { Button, type ButtonVariant } from "../../button/button";
|
|
9
|
+
import type { MediaInputProps } from "./types";
|
|
10
|
+
import { useMediaUpload } from "./useMediaUpload";
|
|
11
|
+
import { MediaPreview } from "./media-preview";
|
|
12
|
+
import { formatBytes } from "./utils";
|
|
13
|
+
import s from "./media-input.module.css";
|
|
14
|
+
|
|
15
|
+
/* This file is intentionally component-only (only exports `MediaInput`)
|
|
16
|
+
so Vite Fast Refresh can preserve component state across edits. Helpers,
|
|
17
|
+
hooks, types, and constants live in their own files and are re-exported
|
|
18
|
+
from the package index - see `packages/core/src/index.ts`. */
|
|
19
|
+
|
|
20
|
+
export const MediaInput = (props: MediaInputProps) => {
|
|
21
|
+
const {
|
|
22
|
+
label,
|
|
23
|
+
className,
|
|
24
|
+
style,
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
info,
|
|
28
|
+
errors,
|
|
29
|
+
layout = "default",
|
|
30
|
+
layoutDirection = "default",
|
|
31
|
+
|
|
32
|
+
accept,
|
|
33
|
+
multiple = false,
|
|
34
|
+
maxFiles,
|
|
35
|
+
maxSize,
|
|
36
|
+
disabled = false,
|
|
37
|
+
readOnly = false,
|
|
38
|
+
required = false,
|
|
39
|
+
|
|
40
|
+
buttonStyle = "none",
|
|
41
|
+
browseButtonVariant = "bordered",
|
|
42
|
+
|
|
43
|
+
showDropzone = true,
|
|
44
|
+
previewLayout = "grid",
|
|
45
|
+
previewSize = "md",
|
|
46
|
+
showRemove = true,
|
|
47
|
+
showClearAll = true,
|
|
48
|
+
|
|
49
|
+
dropzoneTitle,
|
|
50
|
+
dropzoneHint,
|
|
51
|
+
browseLabel,
|
|
52
|
+
dropzoneIcon,
|
|
53
|
+
|
|
54
|
+
renderPreview,
|
|
55
|
+
renderDropzone,
|
|
56
|
+
|
|
57
|
+
previewListClassName,
|
|
58
|
+
previewListStyle,
|
|
59
|
+
|
|
60
|
+
value,
|
|
61
|
+
defaultValue,
|
|
62
|
+
onChange,
|
|
63
|
+
onAccepted,
|
|
64
|
+
onRejected,
|
|
65
|
+
onRemove,
|
|
66
|
+
} = props;
|
|
67
|
+
|
|
68
|
+
const generatedId = useId();
|
|
69
|
+
const inputId = id ?? generatedId;
|
|
70
|
+
const labelId = `${inputId}-label`;
|
|
71
|
+
const hintId = `${inputId}-hint`;
|
|
72
|
+
|
|
73
|
+
const {
|
|
74
|
+
files,
|
|
75
|
+
rejections,
|
|
76
|
+
isDragging,
|
|
77
|
+
open,
|
|
78
|
+
remove,
|
|
79
|
+
clear,
|
|
80
|
+
getRootProps,
|
|
81
|
+
getInputProps,
|
|
82
|
+
} = useMediaUpload({
|
|
83
|
+
value,
|
|
84
|
+
defaultValue,
|
|
85
|
+
onChange,
|
|
86
|
+
onAccepted,
|
|
87
|
+
onRejected,
|
|
88
|
+
onRemove,
|
|
89
|
+
accept,
|
|
90
|
+
multiple,
|
|
91
|
+
maxFiles,
|
|
92
|
+
maxSize,
|
|
93
|
+
disabled,
|
|
94
|
+
readOnly,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const hasFiles = files.length > 0;
|
|
98
|
+
const interactive = !disabled && !readOnly;
|
|
99
|
+
|
|
100
|
+
// Merge rejection messages with consumer-supplied errors so the field message
|
|
101
|
+
// slot stays the single source of validation feedback.
|
|
102
|
+
const mergedErrors = useMemo(() => {
|
|
103
|
+
if (rejections.length === 0) return errors;
|
|
104
|
+
const next = errors ? [...errors] : [];
|
|
105
|
+
rejections.forEach((r) => next.push(r.message));
|
|
106
|
+
return next;
|
|
107
|
+
}, [errors, rejections]);
|
|
108
|
+
|
|
109
|
+
const resolvedTitle =
|
|
110
|
+
dropzoneTitle ??
|
|
111
|
+
(multiple ? "Drop files here or browse" : "Drop a file here or browse");
|
|
112
|
+
const resolvedHint =
|
|
113
|
+
dropzoneHint ??
|
|
114
|
+
[
|
|
115
|
+
accept ? `Accepts ${accept}` : null,
|
|
116
|
+
maxSize ? `Up to ${formatBytes(maxSize)} per file` : null,
|
|
117
|
+
multiple && maxFiles ? `Max ${maxFiles} files` : null,
|
|
118
|
+
]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(" • ");
|
|
121
|
+
|
|
122
|
+
const inputProps = getInputProps({
|
|
123
|
+
id: inputId,
|
|
124
|
+
name,
|
|
125
|
+
required,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Mirrors the SelectButton pattern from common/select-button:
|
|
129
|
+
// outer wrapper holds the variant CSS-variable contracts, inner element
|
|
130
|
+
// is either a default-styled drop target (when "none") or a `<Button as="div"
|
|
131
|
+
// variant=X>` whose full paint becomes the dropzone surface.
|
|
132
|
+
const hasButtonStyle = buttonStyle !== "none";
|
|
133
|
+
const buttonToneClass = hasButtonStyle
|
|
134
|
+
? sButton[`tone_${buttonStyle}`]
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
137
|
+
const browseButton = browseButtonVariant !== "none" && interactive && (
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant={browseButtonVariant as ButtonVariant}
|
|
141
|
+
size="sm"
|
|
142
|
+
onClick={(e: MouseEvent) => {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
open();
|
|
145
|
+
}}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
>
|
|
148
|
+
{browseLabel ?? "Browse files"}
|
|
149
|
+
</Button>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const dropzoneNode = showDropzone ? (
|
|
153
|
+
renderDropzone ? (
|
|
154
|
+
renderDropzone({
|
|
155
|
+
isDragging,
|
|
156
|
+
open,
|
|
157
|
+
disabled,
|
|
158
|
+
readOnly,
|
|
159
|
+
hasFiles,
|
|
160
|
+
})
|
|
161
|
+
) : (
|
|
162
|
+
<div className={s.dropzone_inner}>
|
|
163
|
+
<Icon size="md" className={s.dropzone_icon} aria-hidden="true">
|
|
164
|
+
{dropzoneIcon ?? <IconUpload />}
|
|
165
|
+
</Icon>
|
|
166
|
+
<div className={s.dropzone_copy}>
|
|
167
|
+
<span className={s.dropzone_title}>{resolvedTitle}</span>
|
|
168
|
+
{resolvedHint && (
|
|
169
|
+
<span id={hintId} className={s.dropzone_hint}>
|
|
170
|
+
{resolvedHint}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
{browseButton}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
) : null;
|
|
178
|
+
|
|
179
|
+
// Dropzone is non-selectable when a single-file picker already has its slot
|
|
180
|
+
// filled - remove the existing file first to add a different one.
|
|
181
|
+
const dropzoneSelectable = interactive && (multiple || !hasFiles);
|
|
182
|
+
|
|
183
|
+
const hasLabelAction = hasFiles && showClearAll && interactive;
|
|
184
|
+
|
|
185
|
+
const showLabelRow = Boolean(label) || hasLabelAction;
|
|
186
|
+
|
|
187
|
+
const dropzoneClassName = hasButtonStyle
|
|
188
|
+
? clsx(
|
|
189
|
+
s.dropzone,
|
|
190
|
+
s.dropzone_button_shell,
|
|
191
|
+
sCommon.field_full_width,
|
|
192
|
+
sCommon.field_button_variant,
|
|
193
|
+
sButton.input_focus,
|
|
194
|
+
buttonToneClass,
|
|
195
|
+
isDragging && s.dropzone_active,
|
|
196
|
+
!dropzoneSelectable && s.dropzone_disabled,
|
|
197
|
+
)
|
|
198
|
+
: clsx(
|
|
199
|
+
s.dropzone,
|
|
200
|
+
s.dropzone_default,
|
|
201
|
+
isDragging && s.dropzone_active,
|
|
202
|
+
!dropzoneSelectable && s.dropzone_disabled,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const dropzoneRootProps = getRootProps<HTMLDivElement>({
|
|
206
|
+
className: dropzoneClassName,
|
|
207
|
+
role: "button",
|
|
208
|
+
tabIndex: dropzoneSelectable ? 0 : -1,
|
|
209
|
+
"aria-disabled": !dropzoneSelectable,
|
|
210
|
+
"aria-labelledby": label ? labelId : undefined,
|
|
211
|
+
"aria-describedby": resolvedHint ? hintId : undefined,
|
|
212
|
+
onClick: dropzoneSelectable ? undefined : (e) => e.preventDefault(),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className={clsx(sCommon.container, className)} style={style}>
|
|
217
|
+
{showLabelRow && (
|
|
218
|
+
<div
|
|
219
|
+
className={clsx(
|
|
220
|
+
sCommon.field_container,
|
|
221
|
+
layout !== "default" && sCommon[layout],
|
|
222
|
+
layout !== "default" &&
|
|
223
|
+
layoutDirection !== "default" &&
|
|
224
|
+
sCommon[layoutDirection],
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
<div
|
|
228
|
+
className={clsx(
|
|
229
|
+
hasLabelAction && sCommon.label_row,
|
|
230
|
+
hasLabelAction && !label && sCommon.field_justify_end,
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
{label && (
|
|
234
|
+
<label
|
|
235
|
+
id={labelId}
|
|
236
|
+
className={sCommon.label}
|
|
237
|
+
htmlFor={inputId}
|
|
238
|
+
>
|
|
239
|
+
{label}
|
|
240
|
+
</label>
|
|
241
|
+
)}
|
|
242
|
+
{hasLabelAction && (
|
|
243
|
+
<Button
|
|
244
|
+
type="button"
|
|
245
|
+
variant="ghost"
|
|
246
|
+
size="sm"
|
|
247
|
+
onClick={(e: MouseEvent) => {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
clear();
|
|
250
|
+
}}
|
|
251
|
+
className={s.clear_all}
|
|
252
|
+
>
|
|
253
|
+
<Icon size="sm" aria-hidden="true">
|
|
254
|
+
<IconX />
|
|
255
|
+
</Icon>
|
|
256
|
+
Clear all
|
|
257
|
+
</Button>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
<input {...inputProps} />
|
|
264
|
+
|
|
265
|
+
{showDropzone && (
|
|
266
|
+
<div
|
|
267
|
+
className={clsx(
|
|
268
|
+
s.dropzone_wrapper,
|
|
269
|
+
hasButtonStyle && sCommon.field_wrapper_variant,
|
|
270
|
+
hasButtonStyle && buttonToneClass,
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
{hasButtonStyle ? (
|
|
274
|
+
<Button
|
|
275
|
+
as="div"
|
|
276
|
+
variant={buttonStyle as ButtonVariant}
|
|
277
|
+
isActive={isDragging}
|
|
278
|
+
disabled={disabled}
|
|
279
|
+
{...dropzoneRootProps}
|
|
280
|
+
>
|
|
281
|
+
{dropzoneNode}
|
|
282
|
+
</Button>
|
|
283
|
+
) : (
|
|
284
|
+
<div {...dropzoneRootProps}>{dropzoneNode}</div>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
<FieldMessage errors={mergedErrors} info={info} />
|
|
290
|
+
|
|
291
|
+
{hasFiles && previewLayout !== "none" && (
|
|
292
|
+
<ul
|
|
293
|
+
className={clsx(
|
|
294
|
+
s.preview_list,
|
|
295
|
+
s[`preview_list_${previewSize}`],
|
|
296
|
+
previewLayout === "list" && s.preview_list_layout_list,
|
|
297
|
+
previewListClassName,
|
|
298
|
+
)}
|
|
299
|
+
style={previewListStyle}
|
|
300
|
+
>
|
|
301
|
+
{files.map((file) => (
|
|
302
|
+
<li key={file.id} className={s.preview_item_wrap}>
|
|
303
|
+
{renderPreview ? (
|
|
304
|
+
renderPreview(file, {
|
|
305
|
+
remove: () => remove(file.id),
|
|
306
|
+
disabled,
|
|
307
|
+
readOnly,
|
|
308
|
+
size: previewSize,
|
|
309
|
+
})
|
|
310
|
+
) : (
|
|
311
|
+
<MediaPreview
|
|
312
|
+
file={file}
|
|
313
|
+
layout={previewLayout}
|
|
314
|
+
size={previewSize}
|
|
315
|
+
showRemove={showRemove}
|
|
316
|
+
disabled={disabled}
|
|
317
|
+
readOnly={readOnly}
|
|
318
|
+
onRemove={() => remove(file.id)}
|
|
319
|
+
/>
|
|
320
|
+
)}
|
|
321
|
+
</li>
|
|
322
|
+
))}
|
|
323
|
+
</ul>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type FC, type MouseEvent } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { IconX } from "@tabler/icons-react";
|
|
4
|
+
import { Icon } from "../../../primitives/icon/icon";
|
|
5
|
+
import { Button } from "../../button/button";
|
|
6
|
+
import type {
|
|
7
|
+
MediaFile,
|
|
8
|
+
MediaPreviewLayout,
|
|
9
|
+
MediaPreviewSize,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import { formatBytes, previewKindOf } from "./utils";
|
|
12
|
+
import { KIND_LABEL, iconForKind } from "./kind-icons";
|
|
13
|
+
import s from "./media-input.module.css";
|
|
14
|
+
|
|
15
|
+
export type MediaPreviewProps = {
|
|
16
|
+
file: MediaFile;
|
|
17
|
+
layout: MediaPreviewLayout;
|
|
18
|
+
size: MediaPreviewSize;
|
|
19
|
+
showRemove: boolean;
|
|
20
|
+
disabled: boolean;
|
|
21
|
+
readOnly: boolean;
|
|
22
|
+
onRemove: () => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const MediaPreview: FC<MediaPreviewProps> = ({
|
|
26
|
+
file,
|
|
27
|
+
layout,
|
|
28
|
+
size,
|
|
29
|
+
showRemove,
|
|
30
|
+
disabled,
|
|
31
|
+
readOnly,
|
|
32
|
+
onRemove,
|
|
33
|
+
}) => {
|
|
34
|
+
const kind = previewKindOf(file.file);
|
|
35
|
+
const showRemoveBtn = showRemove && !disabled && !readOnly;
|
|
36
|
+
|
|
37
|
+
const thumb =
|
|
38
|
+
kind === "image" && file.previewUrl ? (
|
|
39
|
+
<img
|
|
40
|
+
src={file.previewUrl}
|
|
41
|
+
alt={file.file.name}
|
|
42
|
+
className={s.preview_image}
|
|
43
|
+
draggable={false}
|
|
44
|
+
/>
|
|
45
|
+
) : kind === "video" && file.previewUrl ? (
|
|
46
|
+
<video
|
|
47
|
+
src={file.previewUrl}
|
|
48
|
+
className={s.preview_image}
|
|
49
|
+
muted
|
|
50
|
+
playsInline
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<div className={s.preview_thumb_fallback} aria-hidden="true">
|
|
54
|
+
<Icon size={layout === "list" ? "sm" : "md"}>{iconForKind(kind)}</Icon>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={clsx(
|
|
61
|
+
s.preview_item,
|
|
62
|
+
layout === "list" ? s.preview_item_list : s[`preview_size_${size}`],
|
|
63
|
+
)}
|
|
64
|
+
data-status={file.status}
|
|
65
|
+
data-kind={kind}
|
|
66
|
+
>
|
|
67
|
+
<div className={s.preview_thumb}>{thumb}</div>
|
|
68
|
+
|
|
69
|
+
<div className={s.preview_meta}>
|
|
70
|
+
<span className={s.preview_name} title={file.file.name}>
|
|
71
|
+
{file.file.name}
|
|
72
|
+
</span>
|
|
73
|
+
<span className={s.preview_sub}>
|
|
74
|
+
<span>{KIND_LABEL[kind]}</span>
|
|
75
|
+
<span aria-hidden="true">·</span>
|
|
76
|
+
<span>{formatBytes(file.file.size)}</span>
|
|
77
|
+
</span>
|
|
78
|
+
{file.error && <span className={s.preview_error}>{file.error}</span>}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{showRemoveBtn && (
|
|
82
|
+
<Button
|
|
83
|
+
type="button"
|
|
84
|
+
variant="ghost"
|
|
85
|
+
size="sm"
|
|
86
|
+
layout="box"
|
|
87
|
+
className={s.preview_remove}
|
|
88
|
+
onClick={(e: MouseEvent) => {
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
onRemove();
|
|
91
|
+
}}
|
|
92
|
+
aria-label={`Remove ${file.file.name}`}
|
|
93
|
+
>
|
|
94
|
+
<Icon size="sm" aria-hidden="true">
|
|
95
|
+
<IconX />
|
|
96
|
+
</Icon>
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import type { ButtonVariant } from "../../button/button";
|
|
3
|
+
import type { BaseInputFieldProps } from "../common/types";
|
|
4
|
+
|
|
5
|
+
/** Standardized rejection reasons surfaced to consumers and the field message slot. */
|
|
6
|
+
export const mediaRejectionCodes = [
|
|
7
|
+
"file-too-large",
|
|
8
|
+
"file-invalid-type",
|
|
9
|
+
"too-many-files",
|
|
10
|
+
"duplicate-file",
|
|
11
|
+
] as const;
|
|
12
|
+
export type MediaRejectionCode = (typeof mediaRejectionCodes)[number];
|
|
13
|
+
|
|
14
|
+
export type MediaRejection = {
|
|
15
|
+
file: File;
|
|
16
|
+
code: MediaRejectionCode;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Closed-set classification used to choose preview icons and labels. Always
|
|
22
|
+
* resolves to a value (never `undefined`) so renderers can rely on it.
|
|
23
|
+
*/
|
|
24
|
+
export const mediaKinds = [
|
|
25
|
+
"image",
|
|
26
|
+
"video",
|
|
27
|
+
"audio",
|
|
28
|
+
"pdf",
|
|
29
|
+
"document",
|
|
30
|
+
"spreadsheet",
|
|
31
|
+
"presentation",
|
|
32
|
+
"archive",
|
|
33
|
+
"code",
|
|
34
|
+
"text",
|
|
35
|
+
"font",
|
|
36
|
+
"other",
|
|
37
|
+
] as const;
|
|
38
|
+
export type MediaKind = (typeof mediaKinds)[number];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stable wrapper around a File. The `id` is generated once when the file is
|
|
42
|
+
* accepted and stays the same across re-renders so previews and DOM keys
|
|
43
|
+
* remain consistent. `previewUrl` is an `URL.createObjectURL` blob that the
|
|
44
|
+
* hook revokes automatically when the file is removed or replaced.
|
|
45
|
+
*/
|
|
46
|
+
export type MediaFile = {
|
|
47
|
+
id: string;
|
|
48
|
+
file: File;
|
|
49
|
+
previewUrl?: string;
|
|
50
|
+
status: "ready" | "uploading" | "uploaded" | "error";
|
|
51
|
+
progress?: number;
|
|
52
|
+
error?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const mediaPreviewLayouts = ["grid", "list", "none"] as const;
|
|
56
|
+
export type MediaPreviewLayout = (typeof mediaPreviewLayouts)[number];
|
|
57
|
+
|
|
58
|
+
export const mediaPreviewSizes = ["sm", "md", "lg"] as const;
|
|
59
|
+
export type MediaPreviewSize = (typeof mediaPreviewSizes)[number];
|
|
60
|
+
|
|
61
|
+
export type MediaInputProps = Omit<
|
|
62
|
+
BaseInputFieldProps,
|
|
63
|
+
"iconStart" | "iconEnd"
|
|
64
|
+
> & {
|
|
65
|
+
/** Native `accept` string (e.g. `image/*`, `image/png,application/pdf`). */
|
|
66
|
+
accept?: string;
|
|
67
|
+
/** Allow selecting multiple files. Default: `false`. */
|
|
68
|
+
multiple?: boolean;
|
|
69
|
+
/** Hard cap on accepted files (only enforced when `multiple`). */
|
|
70
|
+
maxFiles?: number;
|
|
71
|
+
/** Per-file size cap in bytes. */
|
|
72
|
+
maxSize?: number;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Variant for the inner Browse button. Independent from `buttonStyle`
|
|
76
|
+
* (which paints the dropzone surface). Set to `"none"` to hide the
|
|
77
|
+
* Browse button entirely. Default: `"bordered"`.
|
|
78
|
+
*/
|
|
79
|
+
browseButtonVariant?: ButtonVariant | "none";
|
|
80
|
+
|
|
81
|
+
/** Show the dashed dropzone target. Default: `true`. */
|
|
82
|
+
showDropzone?: boolean;
|
|
83
|
+
/** How accepted files are rendered. `"none"` hides previews entirely. */
|
|
84
|
+
previewLayout?: MediaPreviewLayout;
|
|
85
|
+
/** Tile size for grid previews. Default: `"md"`. */
|
|
86
|
+
previewSize?: MediaPreviewSize;
|
|
87
|
+
/** Show the per-file remove (X) action on previews. Default: `true`. */
|
|
88
|
+
showRemove?: boolean;
|
|
89
|
+
/** Show a “Clear all” action when at least one file is selected. */
|
|
90
|
+
showClearAll?: boolean;
|
|
91
|
+
|
|
92
|
+
/** Copy slots - render-prop friendly so consumers can localize freely. */
|
|
93
|
+
dropzoneTitle?: ReactNode;
|
|
94
|
+
dropzoneHint?: ReactNode;
|
|
95
|
+
browseLabel?: ReactNode;
|
|
96
|
+
/** Replaces the icon shown inside the dropzone. */
|
|
97
|
+
dropzoneIcon?: ReactNode;
|
|
98
|
+
|
|
99
|
+
/** Render override for an individual preview tile. */
|
|
100
|
+
renderPreview?: (file: MediaFile, ctx: MediaPreviewContext) => ReactNode;
|
|
101
|
+
/** Render override for the entire dropzone body (keep wrapper styling). */
|
|
102
|
+
renderDropzone?: (ctx: MediaDropzoneContext) => ReactNode;
|
|
103
|
+
|
|
104
|
+
/** Pass-through styling for the inner preview list/grid. */
|
|
105
|
+
previewListClassName?: string;
|
|
106
|
+
previewListStyle?: CSSProperties;
|
|
107
|
+
|
|
108
|
+
value?: MediaFile[];
|
|
109
|
+
defaultValue?: MediaFile[] | File[];
|
|
110
|
+
onChange?: (files: MediaFile[]) => void;
|
|
111
|
+
onRemove?: (file: MediaFile) => void;
|
|
112
|
+
onAccepted?: (files: MediaFile[]) => void;
|
|
113
|
+
onRejected?: (rejections: MediaRejection[]) => void;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type MediaPreviewContext = {
|
|
117
|
+
remove: () => void;
|
|
118
|
+
disabled: boolean;
|
|
119
|
+
readOnly: boolean;
|
|
120
|
+
size: MediaPreviewSize;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type MediaDropzoneContext = {
|
|
124
|
+
isDragging: boolean;
|
|
125
|
+
open: () => void;
|
|
126
|
+
disabled: boolean;
|
|
127
|
+
readOnly: boolean;
|
|
128
|
+
hasFiles: boolean;
|
|
129
|
+
};
|