v-uixy 1.4.0 → 1.5.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/templates/components/Alert/Alert.types.ts +4 -0
- package/templates/components/Alert/AlertItem.component.vue +2 -4
- package/templates/components/Alert/index.ts +6 -1
- package/templates/components/Breadcrumb/Breadcrumb.component.vue +116 -0
- package/templates/components/Breadcrumb/Breadcrumb.styles.ts +40 -0
- package/templates/components/Breadcrumb/Breadcrumb.types.ts +30 -0
- package/templates/components/Breadcrumb/index.ts +17 -0
- package/templates/components/Calendar/Calendar.component.vue +50 -2
- package/templates/components/Calendar/Calendar.styles.ts +4 -0
- package/templates/components/Calendar/Calendar.types.ts +8 -0
- package/templates/components/Chart/Chart.component.vue +308 -0
- package/templates/components/Chart/Chart.types.ts +18 -0
- package/templates/components/Chart/index.ts +13 -0
- package/templates/components/DatePicker/DatePicker.component.vue +219 -0
- package/templates/components/DatePicker/DatePicker.styles.ts +74 -0
- package/templates/components/DatePicker/DatePicker.types.ts +18 -0
- package/templates/components/DatePicker/DatePicker.utils.ts +46 -0
- package/templates/components/DatePicker/TimeWheel.vue +145 -0
- package/templates/components/DatePicker/TimeWheelColumn.vue +147 -0
- package/templates/components/DatePicker/index.ts +11 -0
- package/templates/components/DateRangePicker/DateRangePicker.component.vue +304 -0
- package/templates/components/DateRangePicker/DateRangePicker.types.ts +23 -0
- package/templates/components/DateRangePicker/index.ts +11 -0
- package/templates/components/FileUpload/FileUpload.component.vue +306 -0
- package/templates/components/FileUpload/FileUpload.styles.ts +74 -0
- package/templates/components/FileUpload/FileUpload.types.ts +31 -0
- package/templates/components/FileUpload/index.ts +15 -0
- package/templates/components/InputOTP/InputOTP.component.vue +4 -2
- package/templates/components/InputOTP/InputOTP.types.ts +1 -0
- package/templates/components/SegmentedControl/SegmentedControl.component.vue +85 -0
- package/templates/components/SegmentedControl/SegmentedControl.styles.ts +55 -0
- package/templates/components/SegmentedControl/SegmentedControl.types.ts +15 -0
- package/templates/components/SegmentedControl/index.ts +11 -0
- package/templates/components.json +36 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex w-full flex-col gap-1">
|
|
3
|
+
<label v-if="props.label" :class="labelStyles({ status })">
|
|
4
|
+
{{ props.label }}
|
|
5
|
+
</label>
|
|
6
|
+
|
|
7
|
+
<div
|
|
8
|
+
ref="triggerRef"
|
|
9
|
+
role="button"
|
|
10
|
+
:tabindex="props.disabled ? -1 : 0"
|
|
11
|
+
:aria-expanded="active"
|
|
12
|
+
:class="triggerStyles({ status, open: active })"
|
|
13
|
+
@click="onTriggerClick"
|
|
14
|
+
@keydown.enter.prevent="onTriggerClick"
|
|
15
|
+
@keydown.space.prevent="onTriggerClick"
|
|
16
|
+
>
|
|
17
|
+
<uixy-icon
|
|
18
|
+
:name="props.icon ?? 'calendar'"
|
|
19
|
+
:class="triggerIconStyles({ disabled: props.disabled })"
|
|
20
|
+
/>
|
|
21
|
+
<span
|
|
22
|
+
:class="triggerValueStyles({ placeholder: !hasValue, disabled: props.disabled })"
|
|
23
|
+
>
|
|
24
|
+
{{ displayValue }}
|
|
25
|
+
</span>
|
|
26
|
+
<uixy-icon
|
|
27
|
+
v-if="hasValue && !props.disabled"
|
|
28
|
+
name="close"
|
|
29
|
+
class="h-4 w-4 shrink-0 cursor-pointer text-gray-400 hover:text-black dark:text-gray-600 dark:hover:text-white"
|
|
30
|
+
@click.stop="clear"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div v-if="!props.hideHelper && helperText" class="h-4">
|
|
35
|
+
<p :class="helperStyles({ status })">{{ helperText }}</p>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<teleport to="body">
|
|
39
|
+
<animate-presence>
|
|
40
|
+
<motion.div
|
|
41
|
+
v-if="active"
|
|
42
|
+
:initial="{ opacity: 0 }"
|
|
43
|
+
:animate="{ opacity: 1 }"
|
|
44
|
+
:exit="{ opacity: 0 }"
|
|
45
|
+
:transition="{ duration: 0.15, ease: 'easeInOut' }"
|
|
46
|
+
:style="positionStyles"
|
|
47
|
+
>
|
|
48
|
+
<div ref="refElement">
|
|
49
|
+
<motion.div
|
|
50
|
+
:initial="{ y: 4, scale: 0.96, opacity: 0 }"
|
|
51
|
+
:animate="{ y: 0, scale: 1, opacity: 1 }"
|
|
52
|
+
:exit="{ opacity: 0, scale: 0.96 }"
|
|
53
|
+
:transition="{ duration: 0.15, ease: 'easeInOut' }"
|
|
54
|
+
:class="panelStyles()"
|
|
55
|
+
>
|
|
56
|
+
<div class="flex items-stretch gap-3">
|
|
57
|
+
<uixy-calendar
|
|
58
|
+
:year="viewYear"
|
|
59
|
+
:month="viewMonth"
|
|
60
|
+
:start-of-week="props.startOfWeek"
|
|
61
|
+
:is-date-disabled="props.isDateDisabled"
|
|
62
|
+
:range-start="model.start"
|
|
63
|
+
:range-end="model.end"
|
|
64
|
+
:range-hover="rangeHover"
|
|
65
|
+
@update:model-value="onDayPick"
|
|
66
|
+
@day-hover="onDayHover"
|
|
67
|
+
@day-leave="rangeHover = null"
|
|
68
|
+
/>
|
|
69
|
+
|
|
70
|
+
<div v-if="props.withTime" class="flex gap-3">
|
|
71
|
+
<div class="flex flex-col items-center gap-1">
|
|
72
|
+
<span :class="sectionLabelStyles()">Start</span>
|
|
73
|
+
<uixy-time-wheel
|
|
74
|
+
v-model="startTime"
|
|
75
|
+
:hour-cycle="24"
|
|
76
|
+
:with-seconds="props.withSeconds"
|
|
77
|
+
:disabled="!model.start"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="flex flex-col items-center gap-1">
|
|
81
|
+
<span :class="sectionLabelStyles()">End</span>
|
|
82
|
+
<uixy-time-wheel
|
|
83
|
+
v-model="endTime"
|
|
84
|
+
:hour-cycle="24"
|
|
85
|
+
:with-seconds="props.withSeconds"
|
|
86
|
+
:disabled="!model.end"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div :class="footerStyles()">
|
|
93
|
+
<uixy-button variant="tertiary" size="sm" @click="clear">
|
|
94
|
+
Clear
|
|
95
|
+
</uixy-button>
|
|
96
|
+
<uixy-button variant="primary" size="sm" @click="handleLeave">
|
|
97
|
+
Done
|
|
98
|
+
</uixy-button>
|
|
99
|
+
</div>
|
|
100
|
+
</motion.div>
|
|
101
|
+
</div>
|
|
102
|
+
</motion.div>
|
|
103
|
+
</animate-presence>
|
|
104
|
+
</teleport>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<script setup lang="ts">
|
|
109
|
+
import { AnimatePresence, motion } from "motion-v";
|
|
110
|
+
import { usePosition } from "~/composables";
|
|
111
|
+
import { UixyIcon } from "../Icon";
|
|
112
|
+
import { UixyButton } from "../Button";
|
|
113
|
+
import { UixyCalendar } from "../Calendar";
|
|
114
|
+
import { UixyTimeWheel } from "../DatePicker";
|
|
115
|
+
import type {
|
|
116
|
+
UixyDateRange,
|
|
117
|
+
UixyDateRangePickerProps,
|
|
118
|
+
} from "./DateRangePicker.types";
|
|
119
|
+
import {
|
|
120
|
+
triggerStyles,
|
|
121
|
+
triggerValueStyles,
|
|
122
|
+
triggerIconStyles,
|
|
123
|
+
labelStyles,
|
|
124
|
+
helperStyles,
|
|
125
|
+
panelStyles,
|
|
126
|
+
sectionLabelStyles,
|
|
127
|
+
footerStyles,
|
|
128
|
+
} from "../DatePicker/DatePicker.styles";
|
|
129
|
+
import {
|
|
130
|
+
formatDate,
|
|
131
|
+
timePartsOf,
|
|
132
|
+
withTimeParts,
|
|
133
|
+
startOfDay,
|
|
134
|
+
type UixyTimeParts,
|
|
135
|
+
} from "../DatePicker/DatePicker.utils";
|
|
136
|
+
|
|
137
|
+
const props = defineProps<UixyDateRangePickerProps>();
|
|
138
|
+
|
|
139
|
+
const model = defineModel<UixyDateRange>({
|
|
140
|
+
default: () => ({ start: null, end: null }),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const {
|
|
144
|
+
active,
|
|
145
|
+
styles: positionStyles,
|
|
146
|
+
refElement,
|
|
147
|
+
handleOpen,
|
|
148
|
+
handleLeave,
|
|
149
|
+
} = usePosition({ direction: props.direction ?? "bottom" });
|
|
150
|
+
|
|
151
|
+
const triggerRef = ref<HTMLElement>();
|
|
152
|
+
|
|
153
|
+
const rangeHover = ref<Date | null>(null);
|
|
154
|
+
|
|
155
|
+
const draftStartTime = ref<UixyTimeParts>(timePartsOf(model.value.start));
|
|
156
|
+
|
|
157
|
+
const draftEndTime = ref<UixyTimeParts>(timePartsOf(model.value.end));
|
|
158
|
+
|
|
159
|
+
const status = computed(() => {
|
|
160
|
+
if (props.disabled) return "disabled";
|
|
161
|
+
|
|
162
|
+
return props.status ?? "default";
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const helperText = computed(() => {
|
|
166
|
+
if (status.value === "error") return props.errorText ?? "";
|
|
167
|
+
|
|
168
|
+
return props.helperText ?? "";
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const hasValue = computed(() => !!model.value.start || !!model.value.end);
|
|
172
|
+
|
|
173
|
+
const formatBound = (date: Date | null) =>
|
|
174
|
+
formatDate(date, {
|
|
175
|
+
withTime: props.withTime,
|
|
176
|
+
withSeconds: props.withSeconds,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const displayValue = computed(() => {
|
|
180
|
+
const { start, end } = model.value;
|
|
181
|
+
|
|
182
|
+
if (start && end) return `${formatBound(start)} – ${formatBound(end)}`;
|
|
183
|
+
if (start) return `${formatBound(start)} – …`;
|
|
184
|
+
|
|
185
|
+
return props.placeholder ?? "Select range";
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const viewBase = computed(
|
|
189
|
+
() => model.value.start ?? model.value.end ?? new Date(),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const viewYear = computed(() => viewBase.value.getFullYear());
|
|
193
|
+
|
|
194
|
+
const viewMonth = computed(() => viewBase.value.getMonth());
|
|
195
|
+
|
|
196
|
+
const startTime = computed<UixyTimeParts>({
|
|
197
|
+
get: () =>
|
|
198
|
+
model.value.start ? timePartsOf(model.value.start) : draftStartTime.value,
|
|
199
|
+
set: (parts) => {
|
|
200
|
+
draftStartTime.value = parts;
|
|
201
|
+
|
|
202
|
+
if (!model.value.start) return;
|
|
203
|
+
|
|
204
|
+
model.value = {
|
|
205
|
+
...model.value,
|
|
206
|
+
start: withTimeParts(model.value.start, parts),
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const endTime = computed<UixyTimeParts>({
|
|
212
|
+
get: () =>
|
|
213
|
+
model.value.end ? timePartsOf(model.value.end) : draftEndTime.value,
|
|
214
|
+
set: (parts) => {
|
|
215
|
+
draftEndTime.value = parts;
|
|
216
|
+
|
|
217
|
+
if (!model.value.end) return;
|
|
218
|
+
|
|
219
|
+
model.value = {
|
|
220
|
+
...model.value,
|
|
221
|
+
end: withTimeParts(model.value.end, parts),
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const onDayPick = (date: Date | null | undefined) => {
|
|
227
|
+
if (!date) return;
|
|
228
|
+
|
|
229
|
+
const { start, end } = model.value;
|
|
230
|
+
|
|
231
|
+
if (!start || end) {
|
|
232
|
+
model.value = {
|
|
233
|
+
start: withTimeParts(date, draftStartTime.value),
|
|
234
|
+
end: null,
|
|
235
|
+
};
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const dateIsEarlier = startOfDay(date) <= startOfDay(start);
|
|
240
|
+
const earlier = dateIsEarlier ? date : start;
|
|
241
|
+
const later = dateIsEarlier ? start : date;
|
|
242
|
+
|
|
243
|
+
model.value = {
|
|
244
|
+
start: withTimeParts(earlier, draftStartTime.value),
|
|
245
|
+
end: withTimeParts(later, draftEndTime.value),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
rangeHover.value = null;
|
|
249
|
+
|
|
250
|
+
if (!props.withTime && props.closeOnSelect) handleLeave();
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const onDayHover = (date: Date) => {
|
|
254
|
+
if (model.value.start && !model.value.end) rangeHover.value = date;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onTriggerClick = (event: MouseEvent | KeyboardEvent) => {
|
|
258
|
+
if (props.disabled) return;
|
|
259
|
+
|
|
260
|
+
handleOpen(event as MouseEvent);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const clear = () => {
|
|
264
|
+
model.value = { start: null, end: null };
|
|
265
|
+
rangeHover.value = null;
|
|
266
|
+
|
|
267
|
+
draftStartTime.value = { hour: 0, minute: 0, second: 0 };
|
|
268
|
+
draftEndTime.value = { hour: 0, minute: 0, second: 0 };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
watch(active, (isActive) => {
|
|
272
|
+
if (isActive) {
|
|
273
|
+
draftStartTime.value = timePartsOf(model.value.start);
|
|
274
|
+
draftEndTime.value = timePartsOf(model.value.end);
|
|
275
|
+
} else {
|
|
276
|
+
rangeHover.value = null;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const onDocClick = (event: MouseEvent) => {
|
|
281
|
+
if (!active.value) return;
|
|
282
|
+
|
|
283
|
+
const target = event.target as Node;
|
|
284
|
+
|
|
285
|
+
const clickedInside =
|
|
286
|
+
refElement.value?.contains(target) || triggerRef.value?.contains(target);
|
|
287
|
+
|
|
288
|
+
if (!clickedInside) handleLeave();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
292
|
+
if (active.value && event.key === "Escape") handleLeave();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
onMounted(() => {
|
|
296
|
+
window.addEventListener("click", onDocClick);
|
|
297
|
+
window.addEventListener("keydown", onKeyDown);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
onUnmounted(() => {
|
|
301
|
+
window.removeEventListener("click", onDocClick);
|
|
302
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
303
|
+
});
|
|
304
|
+
</script>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IconName } from "~/types/icons";
|
|
2
|
+
|
|
3
|
+
export interface UixyDateRange {
|
|
4
|
+
start: Date | null;
|
|
5
|
+
end: Date | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UixyDateRangePickerProps {
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
helperText?: string;
|
|
12
|
+
errorText?: string;
|
|
13
|
+
status?: "default" | "error";
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
icon?: IconName;
|
|
16
|
+
withTime?: boolean;
|
|
17
|
+
withSeconds?: boolean;
|
|
18
|
+
startOfWeek?: "Sunday" | "Monday";
|
|
19
|
+
isDateDisabled?: (date: Date) => boolean;
|
|
20
|
+
direction?: "top" | "bottom";
|
|
21
|
+
hideHelper?: boolean;
|
|
22
|
+
closeOnSelect?: boolean;
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import UixyDateRangePicker from "./DateRangePicker.component.vue";
|
|
2
|
+
import {
|
|
3
|
+
type UixyDateRangePickerProps,
|
|
4
|
+
type UixyDateRange,
|
|
5
|
+
} from "./DateRangePicker.types";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
UixyDateRangePicker,
|
|
9
|
+
type UixyDateRangePickerProps,
|
|
10
|
+
type UixyDateRange,
|
|
11
|
+
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex w-full flex-col gap-2">
|
|
3
|
+
<input
|
|
4
|
+
ref="inputRef"
|
|
5
|
+
type="file"
|
|
6
|
+
class="hidden"
|
|
7
|
+
:accept="props.accept"
|
|
8
|
+
:multiple="props.multiple"
|
|
9
|
+
:disabled="props.disabled"
|
|
10
|
+
@change="onInputChange"
|
|
11
|
+
/>
|
|
12
|
+
|
|
13
|
+
<div
|
|
14
|
+
v-if="props.variant === 'button'"
|
|
15
|
+
class="flex flex-wrap items-center gap-3"
|
|
16
|
+
>
|
|
17
|
+
<uixy-button
|
|
18
|
+
:variant="props.buttonVariant ?? 'secondary'"
|
|
19
|
+
:size="props.buttonSize ?? 'md'"
|
|
20
|
+
:icon="props.icon ?? 'upload'"
|
|
21
|
+
:disabled="props.disabled"
|
|
22
|
+
type="button"
|
|
23
|
+
@click="open"
|
|
24
|
+
>
|
|
25
|
+
{{ props.label ?? "Upload file" }}
|
|
26
|
+
</uixy-button>
|
|
27
|
+
<span v-if="props.hint" :class="dropzoneHintStyles()">{{
|
|
28
|
+
props.hint
|
|
29
|
+
}}</span>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div
|
|
33
|
+
v-else
|
|
34
|
+
role="button"
|
|
35
|
+
:tabindex="props.disabled ? -1 : 0"
|
|
36
|
+
:aria-disabled="props.disabled || undefined"
|
|
37
|
+
:class="dropzoneStyles({ state })"
|
|
38
|
+
@click="open"
|
|
39
|
+
@keydown.enter.prevent="open"
|
|
40
|
+
@keydown.space.prevent="open"
|
|
41
|
+
@dragenter.prevent="onDragEnter"
|
|
42
|
+
@dragover.prevent
|
|
43
|
+
@dragleave.prevent="onDragLeave"
|
|
44
|
+
@drop.prevent="onDrop"
|
|
45
|
+
>
|
|
46
|
+
<uixy-icon
|
|
47
|
+
:name="props.icon ?? 'cloud-upload'"
|
|
48
|
+
:class="dropzoneIconStyles({ state })"
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<div class="flex flex-col gap-0.5">
|
|
52
|
+
<span :class="dropzoneLabelStyles({ state })">
|
|
53
|
+
{{ props.label ?? "Click to upload or drag and drop" }}
|
|
54
|
+
</span>
|
|
55
|
+
<span v-if="props.hint" :class="dropzoneHintStyles()">{{
|
|
56
|
+
props.hint
|
|
57
|
+
}}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div v-if="!props.hideList && model.length" :class="fileListStyles()">
|
|
62
|
+
<div
|
|
63
|
+
v-for="(file, index) in model"
|
|
64
|
+
:key="fileKey(file)"
|
|
65
|
+
:class="fileRowStyles()"
|
|
66
|
+
>
|
|
67
|
+
<uixy-icon :name="fileIconFor(file)" :class="fileIconStyles()" />
|
|
68
|
+
<span :class="fileNameStyles()">{{ file.name }}</span>
|
|
69
|
+
<span :class="fileSizeStyles()">{{ formatSize(file.size) }}</span>
|
|
70
|
+
<uixy-icon-button
|
|
71
|
+
icon="close"
|
|
72
|
+
variant="tertiary"
|
|
73
|
+
size="sm"
|
|
74
|
+
:disabled="props.disabled"
|
|
75
|
+
aria-label="Remove file"
|
|
76
|
+
@click="removeFile(index)"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div v-if="text" class="h-4">
|
|
82
|
+
<animate-presence mode="wait" :initial="false">
|
|
83
|
+
<motion.div
|
|
84
|
+
:key="status"
|
|
85
|
+
:initial="{ opacity: 0, y: '-4px' }"
|
|
86
|
+
:animate="{ opacity: 1, y: 0 }"
|
|
87
|
+
:exit="{ opacity: 0, y: '-4px' }"
|
|
88
|
+
:transition="{ duration: 0.125 }"
|
|
89
|
+
>
|
|
90
|
+
<p :class="helperStyles({ status })">{{ text }}</p>
|
|
91
|
+
</motion.div>
|
|
92
|
+
</animate-presence>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<script setup lang="ts">
|
|
98
|
+
import { UixyIcon } from "../Icon";
|
|
99
|
+
import { UixyButton } from "../Button";
|
|
100
|
+
import { UixyIconButton } from "../IconButton";
|
|
101
|
+
import { AnimatePresence, motion } from "motion-v";
|
|
102
|
+
import type { IconName } from "~/types/icons";
|
|
103
|
+
import type {
|
|
104
|
+
UixyFileUploadProps,
|
|
105
|
+
UixyFileUploadEmits,
|
|
106
|
+
UixyFileRejection,
|
|
107
|
+
} from "./FileUpload.types";
|
|
108
|
+
import {
|
|
109
|
+
dropzoneStyles,
|
|
110
|
+
dropzoneIconStyles,
|
|
111
|
+
dropzoneLabelStyles,
|
|
112
|
+
dropzoneHintStyles,
|
|
113
|
+
fileListStyles,
|
|
114
|
+
fileRowStyles,
|
|
115
|
+
fileIconStyles,
|
|
116
|
+
fileNameStyles,
|
|
117
|
+
fileSizeStyles,
|
|
118
|
+
helperStyles,
|
|
119
|
+
} from "./FileUpload.styles";
|
|
120
|
+
|
|
121
|
+
const props = defineProps<UixyFileUploadProps>();
|
|
122
|
+
|
|
123
|
+
const emits = defineEmits<UixyFileUploadEmits>();
|
|
124
|
+
|
|
125
|
+
const model = defineModel<File[]>({ default: () => [] });
|
|
126
|
+
|
|
127
|
+
const inputRef = ref<HTMLInputElement>();
|
|
128
|
+
|
|
129
|
+
const dragDepth = ref(0);
|
|
130
|
+
|
|
131
|
+
const lastError = ref<string | null>(null);
|
|
132
|
+
|
|
133
|
+
const isDragging = computed(() => dragDepth.value > 0);
|
|
134
|
+
|
|
135
|
+
const status = computed(() => {
|
|
136
|
+
if (props.disabled) return "disabled";
|
|
137
|
+
|
|
138
|
+
if (props.status === "error" || lastError.value) return "error";
|
|
139
|
+
|
|
140
|
+
return "default";
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const state = computed(() => {
|
|
144
|
+
if (props.disabled) return "disabled";
|
|
145
|
+
|
|
146
|
+
if (isDragging.value) return "dragging";
|
|
147
|
+
|
|
148
|
+
if (props.status === "error" || lastError.value) return "error";
|
|
149
|
+
|
|
150
|
+
return "default";
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const text = computed(() => {
|
|
154
|
+
if (status.value === "error")
|
|
155
|
+
return props.errorText ?? lastError.value ?? "";
|
|
156
|
+
|
|
157
|
+
if (status.value === "default") return props.helperText ?? "";
|
|
158
|
+
|
|
159
|
+
return "";
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const fileIconFor = (file: File): IconName => {
|
|
163
|
+
if (file.type.startsWith("image/")) return "image";
|
|
164
|
+
|
|
165
|
+
return "file";
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const fileKey = (file: File) =>
|
|
169
|
+
`${file.name}-${file.size}-${file.lastModified}`;
|
|
170
|
+
|
|
171
|
+
const formatSize = (bytes: number) => {
|
|
172
|
+
if (bytes === 0) return "0 B";
|
|
173
|
+
|
|
174
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
175
|
+
const i = Math.min(
|
|
176
|
+
units.length - 1,
|
|
177
|
+
Math.floor(Math.log(bytes) / Math.log(1024)),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const value = bytes / Math.pow(1024, i);
|
|
181
|
+
|
|
182
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[i]}`;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const matchesAccept = (file: File) => {
|
|
186
|
+
const accept = props.accept?.trim();
|
|
187
|
+
if (!accept) return true;
|
|
188
|
+
|
|
189
|
+
const name = file.name.toLowerCase();
|
|
190
|
+
const type = file.type.toLowerCase();
|
|
191
|
+
|
|
192
|
+
return accept
|
|
193
|
+
.split(",")
|
|
194
|
+
.map((token) => token.trim().toLowerCase())
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.some((token) => {
|
|
197
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
198
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
199
|
+
return type === token;
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const rejectionMessage = (rejections: UixyFileRejection[]) => {
|
|
204
|
+
const reasons = new Set(rejections.map((r) => r.reason));
|
|
205
|
+
const parts: string[] = [];
|
|
206
|
+
|
|
207
|
+
if (reasons.has("type")) parts.push("unsupported type");
|
|
208
|
+
if (reasons.has("size") && props.maxSize)
|
|
209
|
+
parts.push(`larger than ${formatSize(props.maxSize)}`);
|
|
210
|
+
if (reasons.has("count")) parts.push("over the file limit");
|
|
211
|
+
|
|
212
|
+
const count = rejections.length;
|
|
213
|
+
|
|
214
|
+
return `${count} file${count > 1 ? "s" : ""} rejected${
|
|
215
|
+
parts.length ? ` — ${parts.join(", ")}` : ""
|
|
216
|
+
}.`;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const open = () => {
|
|
220
|
+
if (props.disabled) return;
|
|
221
|
+
|
|
222
|
+
inputRef.value?.click();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleFiles = (fileList: FileList | null) => {
|
|
226
|
+
if (props.disabled || !fileList) return;
|
|
227
|
+
|
|
228
|
+
const limit = props.multiple ? props.maxFiles : 1;
|
|
229
|
+
const current = props.multiple ? model.value.slice() : [];
|
|
230
|
+
const accepted: File[] = [];
|
|
231
|
+
const rejections: UixyFileRejection[] = [];
|
|
232
|
+
|
|
233
|
+
for (const file of Array.from(fileList)) {
|
|
234
|
+
if (!matchesAccept(file)) {
|
|
235
|
+
rejections.push({ file, reason: "type" });
|
|
236
|
+
} else if (props.maxSize !== undefined && file.size > props.maxSize) {
|
|
237
|
+
rejections.push({ file, reason: "size" });
|
|
238
|
+
} else if (
|
|
239
|
+
limit !== undefined &&
|
|
240
|
+
current.length + accepted.length >= limit
|
|
241
|
+
) {
|
|
242
|
+
rejections.push({ file, reason: "count" });
|
|
243
|
+
} else {
|
|
244
|
+
accepted.push(file);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (accepted.length) {
|
|
249
|
+
const next = props.multiple ? [...current, ...accepted] : [accepted[0]!];
|
|
250
|
+
model.value = next;
|
|
251
|
+
lastError.value = null;
|
|
252
|
+
emits("change", next);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (rejections.length) {
|
|
256
|
+
lastError.value = rejectionMessage(rejections);
|
|
257
|
+
emits("reject", rejections);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (inputRef.value) inputRef.value.value = "";
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const onInputChange = (event: Event) => {
|
|
264
|
+
handleFiles((event.target as HTMLInputElement).files);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const onDrop = (event: DragEvent) => {
|
|
268
|
+
dragDepth.value = 0;
|
|
269
|
+
if (props.disabled) return;
|
|
270
|
+
|
|
271
|
+
handleFiles(event.dataTransfer?.files ?? null);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const onDragEnter = () => {
|
|
275
|
+
if (props.disabled) return;
|
|
276
|
+
|
|
277
|
+
dragDepth.value += 1;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const onDragLeave = () => {
|
|
281
|
+
if (props.disabled) return;
|
|
282
|
+
|
|
283
|
+
dragDepth.value = Math.max(0, dragDepth.value - 1);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const removeFile = (index: number) => {
|
|
287
|
+
if (props.disabled) return;
|
|
288
|
+
|
|
289
|
+
const next = model.value.slice();
|
|
290
|
+
next.splice(index, 1);
|
|
291
|
+
|
|
292
|
+
model.value = next;
|
|
293
|
+
lastError.value = null;
|
|
294
|
+
|
|
295
|
+
emits("change", next);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const clear = () => {
|
|
299
|
+
model.value = [];
|
|
300
|
+
lastError.value = null;
|
|
301
|
+
|
|
302
|
+
emits("change", []);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
defineExpose({ open, clear });
|
|
306
|
+
</script>
|