@treeui/vue 0.12.0 → 0.14.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/README.md +5 -1
- package/dist/components/TButton.cjs.map +1 -1
- package/dist/components/TButton.js.map +1 -1
- package/dist/components/TButton.vue.d.ts +2 -2
- package/dist/components/TButton.vue.d.ts.map +1 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.js +2 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TChart.cjs +2 -0
- package/dist/components/TChart.cjs.map +1 -0
- package/dist/components/TChart.js +7 -0
- package/dist/components/TChart.js.map +1 -0
- package/dist/components/TChart.vue.d.ts +123 -0
- package/dist/components/TChart.vue.d.ts.map +1 -0
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.js +370 -0
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TDonutChart.cjs +2 -0
- package/dist/components/TDonutChart.cjs.map +1 -0
- package/dist/components/TDonutChart.js +7 -0
- package/dist/components/TDonutChart.js.map +1 -0
- package/dist/components/TDonutChart.vue.d.ts +75 -0
- package/dist/components/TDonutChart.vue.d.ts.map +1 -0
- package/dist/components/TDonutChart.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TDonutChart.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TDonutChart.vue_vue_type_script_setup_true_lang.js +168 -0
- package/dist/components/TDonutChart.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TFileUpload.cjs.map +1 -1
- package/dist/components/TFileUpload.js.map +1 -1
- package/dist/components/TFileUpload.vue.d.ts +77 -10
- package/dist/components/TFileUpload.vue.d.ts.map +1 -1
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.js +393 -144
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TGrid.vue.d.ts +1 -1
- package/dist/components/TGrid.vue.d.ts.map +1 -1
- package/dist/components/TNavMenu.cjs.map +1 -1
- package/dist/components/TNavMenu.js.map +1 -1
- package/dist/components/TNavMenu.vue.d.ts +9 -0
- package/dist/components/TNavMenu.vue.d.ts.map +1 -1
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.js +67 -55
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TPage.cjs +2 -0
- package/dist/components/TPage.cjs.map +1 -0
- package/dist/components/TPage.js +7 -0
- package/dist/components/TPage.js.map +1 -0
- package/dist/components/TPage.vue.d.ts +35 -0
- package/dist/components/TPage.vue.d.ts.map +1 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.js +49 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TPageHeader.cjs +2 -0
- package/dist/components/TPageHeader.cjs.map +1 -0
- package/dist/components/TPageHeader.js +7 -0
- package/dist/components/TPageHeader.js.map +1 -0
- package/dist/components/TPageHeader.vue.d.ts +40 -0
- package/dist/components/TPageHeader.vue.d.ts.map +1 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.js +54 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.js +1 -4
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TSparkline.cjs +2 -0
- package/dist/components/TSparkline.cjs.map +1 -0
- package/dist/components/TSparkline.js +7 -0
- package/dist/components/TSparkline.js.map +1 -0
- package/dist/components/TSparkline.vue.d.ts +40 -0
- package/dist/components/TSparkline.vue.d.ts.map +1 -0
- package/dist/components/TSparkline.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TSparkline.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TSparkline.vue_vue_type_script_setup_true_lang.js +107 -0
- package/dist/components/TSparkline.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TStack.vue.d.ts +1 -1
- package/dist/components/TStack.vue.d.ts.map +1 -1
- package/dist/components/index.d.ts +9 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +51 -46
- package/dist/plugin.cjs +1 -1
- package/dist/plugin.cjs.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +100 -90
- package/dist/plugin.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Fragment as e, computed as t, createCommentVNode as n, createElementBlock as r, createElementVNode as i, createTextVNode as a, defineComponent as o, normalizeClass as s, normalizeStyle as c, openBlock as l, ref as u, renderList as d, renderSlot as f, toDisplayString as p } from "vue";
|
|
2
|
+
import { donutSegments as m } from "@treeui/utils";
|
|
3
|
+
//#region src/components/TDonutChart.vue?vue&type=script&setup=true&lang.ts
|
|
4
|
+
var h = {
|
|
5
|
+
key: 0,
|
|
6
|
+
class: "t-donut-chart__skeleton"
|
|
7
|
+
}, g = {
|
|
8
|
+
key: 1,
|
|
9
|
+
class: "t-donut-chart__empty"
|
|
10
|
+
}, _ = [
|
|
11
|
+
"width",
|
|
12
|
+
"height",
|
|
13
|
+
"viewBox",
|
|
14
|
+
"aria-label"
|
|
15
|
+
], v = ["transform"], y = [
|
|
16
|
+
"cx",
|
|
17
|
+
"cy",
|
|
18
|
+
"r",
|
|
19
|
+
"stroke",
|
|
20
|
+
"stroke-width",
|
|
21
|
+
"stroke-linecap",
|
|
22
|
+
"stroke-dasharray",
|
|
23
|
+
"stroke-dashoffset",
|
|
24
|
+
"onPointerenter"
|
|
25
|
+
], b = ["d", "onPointerenter"], x = {
|
|
26
|
+
key: 0,
|
|
27
|
+
class: "t-donut-chart__center"
|
|
28
|
+
}, S = { class: "t-donut-chart__center-value" }, C = { class: "t-donut-chart__center-label" }, w = {
|
|
29
|
+
key: 0,
|
|
30
|
+
class: "t-donut-chart__legend"
|
|
31
|
+
}, T = ["onPointerenter"], E = { class: "t-donut-chart__legend-label" }, D = { class: "t-donut-chart__legend-value" }, O = {
|
|
32
|
+
key: 0,
|
|
33
|
+
class: "t-donut-chart__legend-percent"
|
|
34
|
+
}, k = /*@__PURE__*/ o({
|
|
35
|
+
__name: "TDonutChart",
|
|
36
|
+
props: {
|
|
37
|
+
segments: { default: () => [] },
|
|
38
|
+
size: { default: 200 },
|
|
39
|
+
thickness: { default: 26 },
|
|
40
|
+
gap: { default: 2 },
|
|
41
|
+
rounded: {
|
|
42
|
+
type: Boolean,
|
|
43
|
+
default: !1
|
|
44
|
+
},
|
|
45
|
+
showLegend: {
|
|
46
|
+
type: Boolean,
|
|
47
|
+
default: !0
|
|
48
|
+
},
|
|
49
|
+
showPercent: {
|
|
50
|
+
type: Boolean,
|
|
51
|
+
default: !0
|
|
52
|
+
},
|
|
53
|
+
centerLabel: { default: void 0 },
|
|
54
|
+
centerValue: { default: void 0 },
|
|
55
|
+
valueFormat: {
|
|
56
|
+
type: Function,
|
|
57
|
+
default: void 0
|
|
58
|
+
},
|
|
59
|
+
loading: {
|
|
60
|
+
type: Boolean,
|
|
61
|
+
default: !1
|
|
62
|
+
},
|
|
63
|
+
ariaLabel: { default: void 0 }
|
|
64
|
+
},
|
|
65
|
+
setup(o) {
|
|
66
|
+
let k = o, A = u(null), j = t(() => k.segments.filter((e) => e.value > 0)), M = t(() => j.value.reduce((e, t) => e + t.value, 0)), N = t(() => M.value > 0), P = t(() => k.thickness > 0), F = t(() => k.size / 2), I = (e, t) => t.color ?? `var(--tree-color-chart-${e % 8 + 1})`, L = t(() => {
|
|
67
|
+
let e = j.value.length === 1;
|
|
68
|
+
if (P.value) {
|
|
69
|
+
let t = k.thickness / 2 + 3, n = k.size / 2 - t, r = 2 * Math.PI * n, i = k.gap / 360 * r, a = e ? 0 : k.rounded ? Math.max(i, k.thickness + 2) : Math.max(i, 2), o = 0;
|
|
70
|
+
return j.value.map((e, t) => {
|
|
71
|
+
let i = e.value / M.value, s = i * r, c = Math.max(.5, s - a), l = {
|
|
72
|
+
mode: "arc",
|
|
73
|
+
r: n,
|
|
74
|
+
circumference: r,
|
|
75
|
+
dashArray: `${c} ${r - c}`,
|
|
76
|
+
dashOffset: -o,
|
|
77
|
+
color: I(t, e),
|
|
78
|
+
label: e.label,
|
|
79
|
+
value: e.value,
|
|
80
|
+
fraction: i
|
|
81
|
+
};
|
|
82
|
+
return o += s, l;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
let t = k.size / 2 - 2;
|
|
86
|
+
return m(j.value.map((e) => e.value), {
|
|
87
|
+
cx: F.value,
|
|
88
|
+
cy: F.value,
|
|
89
|
+
radius: t,
|
|
90
|
+
innerRadius: 0,
|
|
91
|
+
gap: e ? 0 : k.gap
|
|
92
|
+
}).map((e, t) => {
|
|
93
|
+
let n = j.value[t];
|
|
94
|
+
return {
|
|
95
|
+
mode: "wedge",
|
|
96
|
+
path: e.path,
|
|
97
|
+
fraction: e.fraction,
|
|
98
|
+
color: I(t, n),
|
|
99
|
+
label: n.label,
|
|
100
|
+
value: n.value
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}), R = (e) => k.valueFormat ? k.valueFormat(e) : Math.abs(e) >= 1e3 ? e.toLocaleString("en-US") : `${e}`, z = (e) => `${Math.round(e * 100)}%`, B = t(() => A.value === null ? null : j.value[A.value] ?? null), V = t(() => B.value ? R(B.value.value) : k.centerValue === void 0 ? R(M.value) : `${k.centerValue}`), H = t(() => B.value ? B.value.label : k.centerLabel ?? "Total"), U = t(() => k.ariaLabel ?? `${P.value ? "Donut" : "Pie"} chart with ${j.value.length} segments`), W = t(() => ["t-donut-chart", {
|
|
104
|
+
"is-loading": k.loading,
|
|
105
|
+
"has-legend": k.showLegend
|
|
106
|
+
}]);
|
|
107
|
+
return (t, u) => (l(), r("div", { class: s(W.value) }, [i("div", {
|
|
108
|
+
class: "t-donut-chart__figure",
|
|
109
|
+
style: c({
|
|
110
|
+
width: `${o.size}px`,
|
|
111
|
+
height: `${o.size}px`
|
|
112
|
+
})
|
|
113
|
+
}, [o.loading ? (l(), r("div", h)) : N.value ? (l(), r(e, { key: 2 }, [(l(), r("svg", {
|
|
114
|
+
class: "t-donut-chart__svg",
|
|
115
|
+
width: o.size,
|
|
116
|
+
height: o.size,
|
|
117
|
+
viewBox: `0 0 ${o.size} ${o.size}`,
|
|
118
|
+
role: "img",
|
|
119
|
+
"aria-label": U.value
|
|
120
|
+
}, [i("g", { transform: P.value ? `rotate(-90 ${F.value} ${F.value})` : void 0 }, [(l(!0), r(e, null, d(L.value, (t, n) => (l(), r(e, { key: n }, [t.mode === "arc" ? (l(), r("circle", {
|
|
121
|
+
key: 0,
|
|
122
|
+
class: s(["t-donut-chart__segment t-donut-chart__segment--arc", { "is-dimmed": A.value !== null && A.value !== n }]),
|
|
123
|
+
cx: F.value,
|
|
124
|
+
cy: F.value,
|
|
125
|
+
r: t.r,
|
|
126
|
+
fill: "none",
|
|
127
|
+
stroke: t.color,
|
|
128
|
+
"stroke-width": A.value === n ? o.thickness + 4 : o.thickness,
|
|
129
|
+
"stroke-linecap": o.rounded ? "round" : "butt",
|
|
130
|
+
"stroke-dasharray": t.dashArray,
|
|
131
|
+
"stroke-dashoffset": t.dashOffset,
|
|
132
|
+
style: c({ animationDelay: `${n * 70}ms` }),
|
|
133
|
+
onPointerenter: (e) => A.value = n,
|
|
134
|
+
onPointerleave: u[0] ||= (e) => A.value = null
|
|
135
|
+
}, null, 46, y)) : (l(), r("path", {
|
|
136
|
+
key: 1,
|
|
137
|
+
class: s(["t-donut-chart__segment t-donut-chart__segment--wedge", { "is-dimmed": A.value !== null && A.value !== n }]),
|
|
138
|
+
d: t.path,
|
|
139
|
+
style: c({
|
|
140
|
+
fill: t.color,
|
|
141
|
+
animationDelay: `${n * 70}ms`
|
|
142
|
+
}),
|
|
143
|
+
onPointerenter: (e) => A.value = n,
|
|
144
|
+
onPointerleave: u[1] ||= (e) => A.value = null
|
|
145
|
+
}, null, 46, b))], 64))), 128))], 8, v)], 8, _)), P.value ? (l(), r("div", x, [f(t.$slots, "center", {
|
|
146
|
+
total: M.value,
|
|
147
|
+
active: B.value
|
|
148
|
+
}, () => [i("span", S, p(V.value), 1), i("span", C, p(H.value), 1)])])) : n("", !0)], 64)) : (l(), r("div", g, [f(t.$slots, "empty", {}, () => [u[3] ||= a(" No data ", -1)])]))], 4), o.showLegend && N.value ? (l(), r("ul", w, [(l(!0), r(e, null, d(L.value, (e, t) => (l(), r("li", {
|
|
149
|
+
key: t,
|
|
150
|
+
class: s(["t-donut-chart__legend-item", { "is-active": A.value === t }]),
|
|
151
|
+
onPointerenter: (e) => A.value = t,
|
|
152
|
+
onPointerleave: u[2] ||= (e) => A.value = null
|
|
153
|
+
}, [
|
|
154
|
+
i("span", {
|
|
155
|
+
class: "t-donut-chart__legend-swatch",
|
|
156
|
+
style: c({ background: e.color }),
|
|
157
|
+
"aria-hidden": "true"
|
|
158
|
+
}, null, 4),
|
|
159
|
+
i("span", E, p(e.label), 1),
|
|
160
|
+
i("span", D, p(R(e.value)), 1),
|
|
161
|
+
o.showPercent ? (l(), r("span", O, p(z(e.fraction)), 1)) : n("", !0)
|
|
162
|
+
], 42, T))), 128))])) : n("", !0)], 2));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
//#endregion
|
|
166
|
+
export { k as default };
|
|
167
|
+
|
|
168
|
+
//# sourceMappingURL=TDonutChart.vue_vue_type_script_setup_true_lang.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TDonutChart.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/TDonutChart.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport { donutSegments } from '@treeui/utils';\n\nexport interface TDonutSegment {\n /** Legend + tooltip label. */\n label: string;\n /** Slice magnitude; non-positive values are omitted. */\n value: number;\n /** Override the slice color; defaults to a chart palette token. */\n color?: string;\n}\n\nconst props = withDefaults(\n defineProps<{\n /** Slices to draw, in order. */\n segments?: TDonutSegment[];\n /** Square canvas size in px. */\n size?: number;\n /** Ring thickness in px. `0` renders a full pie. */\n thickness?: number;\n /** Gap between slices, in degrees. */\n gap?: number;\n /** Round the ends of donut segments (adds a larger gap to make room). */\n rounded?: boolean;\n /** Show the legend with values. */\n showLegend?: boolean;\n /** Show each slice's percentage in the legend. */\n showPercent?: boolean;\n /** Static label under the center value (donut only). */\n centerLabel?: string;\n /** Static value shown in the center (donut only). Overrides the auto total. */\n centerValue?: string | number;\n /** Format legend + center values. */\n valueFormat?: (value: number) => string;\n /** Render skeleton placeholders instead of the chart. */\n loading?: boolean;\n /** Accessible summary of the chart. */\n ariaLabel?: string;\n }>(),\n {\n segments: () => [],\n size: 200,\n thickness: 26,\n gap: 2,\n rounded: false,\n showLegend: true,\n showPercent: true,\n centerLabel: undefined,\n centerValue: undefined,\n valueFormat: undefined,\n loading: false,\n ariaLabel: undefined,\n },\n);\n\ndefineSlots<{\n center?: (props: { total: number; active: TDonutSegment | null }) => unknown;\n empty?: () => unknown;\n}>();\n\nconst activeIndex = ref<number | null>(null);\n\nconst visibleSegments = computed(() => props.segments.filter((item) => item.value > 0));\n\nconst total = computed(() =>\n visibleSegments.value.reduce((sum, item) => sum + item.value, 0),\n);\n\nconst hasData = computed(() => total.value > 0);\n\nconst isDonut = computed(() => props.thickness > 0);\n\nconst center = computed(() => props.size / 2);\n\nconst colorFor = (index: number, segment: TDonutSegment) =>\n segment.color ?? `var(--tree-color-chart-${(index % 8) + 1})`;\n\n// Donut segments are drawn as stroked arcs on a mid-radius circle, so the ring\n// stays perfectly smooth and gaps are one consistent width. Pie slices need a\n// filled wedge to the center, so they keep the path geometry.\nconst geometry = computed(() => {\n const single = visibleSegments.value.length === 1;\n\n if (isDonut.value) {\n // Leave room for the stroke width and the hover growth so nothing clips.\n const pad = props.thickness / 2 + 3;\n const midRadius = props.size / 2 - pad;\n const circumference = 2 * Math.PI * midRadius;\n const degreeGap = (props.gap / 360) * circumference;\n const gapPx = single\n ? 0\n : props.rounded\n ? Math.max(degreeGap, props.thickness + 2)\n : Math.max(degreeGap, 2);\n\n let start = 0;\n return visibleSegments.value.map((item, index) => {\n const fraction = item.value / total.value;\n const length = fraction * circumference;\n const on = Math.max(0.5, length - gapPx);\n const arc = {\n mode: 'arc' as const,\n r: midRadius,\n circumference,\n dashArray: `${on} ${circumference - on}`,\n dashOffset: -start,\n color: colorFor(index, item),\n label: item.label,\n value: item.value,\n fraction,\n };\n start += length;\n return arc;\n });\n }\n\n const radius = props.size / 2 - 2;\n const arcs = donutSegments(\n visibleSegments.value.map((item) => item.value),\n { cx: center.value, cy: center.value, radius, innerRadius: 0, gap: single ? 0 : props.gap },\n );\n\n return arcs.map((arc, index) => {\n const source = visibleSegments.value[index];\n return {\n mode: 'wedge' as const,\n path: arc.path,\n fraction: arc.fraction,\n color: colorFor(index, source),\n label: source.label,\n value: source.value,\n };\n });\n});\n\nconst formatValue = (value: number) => {\n if (props.valueFormat) return props.valueFormat(value);\n if (Math.abs(value) >= 1000) return value.toLocaleString('en-US');\n return `${value}`;\n};\n\nconst formatPercent = (fraction: number) => `${Math.round(fraction * 100)}%`;\n\nconst activeSegment = computed(() =>\n activeIndex.value === null ? null : visibleSegments.value[activeIndex.value] ?? null,\n);\n\nconst centerPrimary = computed(() => {\n if (activeSegment.value) return formatValue(activeSegment.value.value);\n if (props.centerValue !== undefined) return `${props.centerValue}`;\n return formatValue(total.value);\n});\n\nconst centerSecondary = computed(() => {\n if (activeSegment.value) return activeSegment.value.label;\n return props.centerLabel ?? 'Total';\n});\n\nconst resolvedLabel = computed(\n () => props.ariaLabel ?? `${isDonut.value ? 'Donut' : 'Pie'} chart with ${visibleSegments.value.length} segments`,\n);\n\nconst rootClasses = computed(() => [\n 't-donut-chart',\n { 'is-loading': props.loading, 'has-legend': props.showLegend },\n]);\n</script>\n\n<template>\n <div :class=\"rootClasses\">\n <div\n class=\"t-donut-chart__figure\"\n :style=\"{ width: `${size}px`, height: `${size}px` }\"\n >\n <div\n v-if=\"loading\"\n class=\"t-donut-chart__skeleton\"\n />\n\n <div\n v-else-if=\"!hasData\"\n class=\"t-donut-chart__empty\"\n >\n <slot name=\"empty\">\n No data\n </slot>\n </div>\n\n <template v-else>\n <svg\n class=\"t-donut-chart__svg\"\n :width=\"size\"\n :height=\"size\"\n :viewBox=\"`0 0 ${size} ${size}`\"\n role=\"img\"\n :aria-label=\"resolvedLabel\"\n >\n <g :transform=\"isDonut ? `rotate(-90 ${center} ${center})` : undefined\">\n <template\n v-for=\"(segment, index) in geometry\"\n :key=\"index\"\n >\n <circle\n v-if=\"segment.mode === 'arc'\"\n class=\"t-donut-chart__segment t-donut-chart__segment--arc\"\n :class=\"{ 'is-dimmed': activeIndex !== null && activeIndex !== index }\"\n :cx=\"center\"\n :cy=\"center\"\n :r=\"segment.r\"\n fill=\"none\"\n :stroke=\"segment.color\"\n :stroke-width=\"activeIndex === index ? thickness + 4 : thickness\"\n :stroke-linecap=\"rounded ? 'round' : 'butt'\"\n :stroke-dasharray=\"segment.dashArray\"\n :stroke-dashoffset=\"segment.dashOffset\"\n :style=\"{ animationDelay: `${index * 70}ms` }\"\n @pointerenter=\"activeIndex = index\"\n @pointerleave=\"activeIndex = null\"\n />\n <path\n v-else\n class=\"t-donut-chart__segment t-donut-chart__segment--wedge\"\n :class=\"{ 'is-dimmed': activeIndex !== null && activeIndex !== index }\"\n :d=\"segment.path\"\n :style=\"{ fill: segment.color, animationDelay: `${index * 70}ms` }\"\n @pointerenter=\"activeIndex = index\"\n @pointerleave=\"activeIndex = null\"\n />\n </template>\n </g>\n </svg>\n\n <div\n v-if=\"isDonut\"\n class=\"t-donut-chart__center\"\n >\n <slot\n name=\"center\"\n :total=\"total\"\n :active=\"activeSegment\"\n >\n <span class=\"t-donut-chart__center-value\">{{ centerPrimary }}</span>\n <span class=\"t-donut-chart__center-label\">{{ centerSecondary }}</span>\n </slot>\n </div>\n </template>\n </div>\n\n <ul\n v-if=\"showLegend && hasData\"\n class=\"t-donut-chart__legend\"\n >\n <li\n v-for=\"(segment, index) in geometry\"\n :key=\"index\"\n class=\"t-donut-chart__legend-item\"\n :class=\"{ 'is-active': activeIndex === index }\"\n @pointerenter=\"activeIndex = index\"\n @pointerleave=\"activeIndex = null\"\n >\n <span\n class=\"t-donut-chart__legend-swatch\"\n :style=\"{ background: segment.color }\"\n aria-hidden=\"true\"\n />\n <span class=\"t-donut-chart__legend-label\">{{ segment.label }}</span>\n <span class=\"t-donut-chart__legend-value\">{{ formatValue(segment.value) }}</span>\n <span\n v-if=\"showPercent\"\n class=\"t-donut-chart__legend-percent\"\n >{{ formatPercent(segment.fraction) }}</span>\n </li>\n </ul>\n </div>\n</template>\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TFileUpload.cjs","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: {\n file: File;\n index: number;\n removeFile: () => void;\n }) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isUnavailable = computed(() => props.disabled || props.loading);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isUnavailable.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst fileKey = (file: File, index: number) =>\n `${file.name}-${file.size}-${file.lastModified}-${index}`;\n\nconst matchesAccept = (file: File) => {\n if (!props.accept.trim()) {\n return true;\n }\n\n const tokens = props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean);\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return tokens.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isUnavailable.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isUnavailable.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst removeFile = (index: number) => {\n if (isUnavailable.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isUnavailable.value || !hasFiles.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isUnavailable.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isUnavailable.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isUnavailable\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isUnavailable || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isUnavailable ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ label }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"(file, index) in modelValue\"\n :key=\"fileKey(file, index)\"\n class=\"t-file-upload__file\"\n >\n <slot\n name=\"file\"\n :file=\"file\"\n :index=\"index\"\n :remove-file=\"() => removeFile(index)\"\n >\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ formatFileSize(file.size) }}\n </p>\n </div>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n :aria-label=\"`Remove ${file.name}`\"\n @click=\"removeFile(index)\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"TFileUpload.cjs","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TProgress from './TProgress.vue';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nexport type TFileUploadStatus =\n | 'pending'\n | 'uploading'\n | 'paused'\n | 'error'\n | 'success';\n\nexport interface TFileUploadState {\n status: TFileUploadStatus;\n progress?: number;\n uploadedBytes?: number;\n remainingMs?: number;\n remainingLabel?: string;\n error?: string;\n resumable?: boolean;\n retryable?: boolean;\n thumbnailUrl?: string;\n meta?: string;\n}\n\nexport type TFileUploadStateSource =\n | ReadonlyMap<File, TFileUploadState>\n | ((file: File, index: number) => TFileUploadState | undefined);\n\nexport interface TFileUploadRetryPayload {\n file: File;\n index: number;\n fileKey: string;\n mode: 'resume' | 'restart';\n state: TFileUploadState;\n}\n\nexport interface TFileUploadCancelPayload {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState;\n reason: 'remove' | 'clear';\n}\n\nexport interface TFileUploadFileSlotProps {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState | undefined;\n thumbnailUrl: string | undefined;\n sizeLabel: string;\n typeLabel: string;\n dimensionsLabel: string | undefined;\n progressLabel: string | undefined;\n remainingLabel: string | undefined;\n uploadedLabel: string | undefined;\n statusLabel: string | undefined;\n actionLabel: string | undefined;\n canRetry: boolean;\n retryMode: 'resume' | 'restart';\n isDragActive: boolean;\n isDragReject: boolean;\n removeFile: () => void;\n retryFile: () => void;\n}\n\ninterface ThumbEntry {\n url: string;\n width: number | null;\n height: number | null;\n}\n\ninterface FileRow {\n slotProps: TFileUploadFileSlotProps;\n rowClass: string | undefined;\n metaText: string;\n statusText: string;\n errorText: string | undefined;\n errorId: string | undefined;\n showProgress: boolean;\n progressValue: number | undefined;\n progressAriaLabel: string;\n isBusy: boolean;\n isSuccess: boolean;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<TFileUploadStatus, string> = {\n pending: 'Waiting',\n uploading: 'Uploading',\n paused: 'Paused',\n error: 'Upload failed',\n success: 'Uploaded',\n};\n\n// The status log keeps a bounded history of announcements. Old entries are\n// trimmed rather than replaced in place so that each terminal transition\n// inserts a brand new node: `aria-relevant` defaults to \"additions text\", so\n// assistive tech announces the insertion and ignores the trim.\nconst STATUS_LOG_LIMIT = 5;\n\ntype StatusLogEntry = {\n id: number;\n text: string;\n};\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n isDragReject: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: TFileUploadFileSlotProps) => unknown;\n 'file-status'?: (props: TFileUploadFileSlotProps) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n uploadState?: TFileUploadStateSource;\n dragLabel?: string;\n dragRejectLabel?: string;\n thumbnails?: boolean;\n maxThumbnailSize?: number | null;\n retryLabel?: string;\n resumeLabel?: (percent: number) => string;\n remainingTimeFormat?: (remainingMs: number) => string;\n statusLabels?: Partial<Record<TFileUploadStatus, string>>;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n uploadState: undefined,\n dragLabel: 'Release to upload',\n dragRejectLabel: 'This file type is not accepted',\n thumbnails: true,\n maxThumbnailSize: 10 * 1024 * 1024,\n retryLabel: 'Retry',\n resumeLabel: (percent: number) => `Resume from ${percent}%`,\n remainingTimeFormat: (remainingMs: number) => {\n if (remainingMs < 5000) {\n return 'Less than 5s left';\n }\n\n const seconds = Math.round(remainingMs / 1000);\n\n if (seconds < 60) {\n return `About ${seconds}s left`;\n }\n\n const minutes = Math.round(seconds / 60);\n\n if (minutes < 60) {\n return `About ${minutes} min left`;\n }\n\n return `About ${Math.round(minutes / 60)}h left`;\n },\n statusLabels: () => ({}),\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n retry: [payload: TFileUploadRetryPayload];\n cancel: [payload: TFileUploadCancelPayload];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isDragReject = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\nconst statusLog = ref<StatusLogEntry[]>([]);\nlet statusLogId = 0;\nconst retryFocusKey = ref<string | null>(null);\nconst thumbs = ref(new Map<File, ThumbEntry>());\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isDisabled = computed(() => props.disabled);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst resolvedStatusLabels = computed(() => ({\n ...DEFAULT_STATUS_LABELS,\n ...props.statusLabels,\n}));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isDisabled.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'is-drag-reject': isDragReject.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst dropzoneLabel = computed(() => {\n if (isDragReject.value) {\n return props.dragRejectLabel;\n }\n\n return isDragActive.value ? props.dragLabel : props.label;\n});\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst formatFileType = (file: File) => {\n const dotIndex = file.name.lastIndexOf('.');\n const extension = dotIndex > 0 && dotIndex < file.name.length - 1\n ? file.name.slice(dotIndex + 1).toUpperCase()\n : '';\n\n if (extension && extension.length <= 5) {\n return extension;\n }\n\n const subtype = file.type.includes('/') ? file.type.split('/')[1] : '';\n\n return subtype ? subtype.toUpperCase() : 'FILE';\n};\n\nlet keySeq = 0;\nconst keyCache = new WeakMap<File, string>();\n\nconst fileKeyFor = (file: File) => {\n let key = keyCache.get(file);\n\n if (!key) {\n keySeq += 1;\n key = `${uploadId}-f${keySeq}`;\n keyCache.set(file, key);\n }\n\n return key;\n};\n\nconst acceptTokens = computed(() =>\n props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean),\n);\n\nconst matchesAccept = (file: File) => {\n if (acceptTokens.value.length === 0) {\n return true;\n }\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return acceptTokens.value.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst canDetectDragReject = computed(() =>\n acceptTokens.value.length > 0 && acceptTokens.value.every((token) => token.includes('/')),\n);\n\nconst matchesAcceptType = (type: string) => {\n const fileType = type.toLowerCase();\n\n if (!fileType) {\n return true;\n }\n\n return acceptTokens.value.some((token) =>\n token.endsWith('/*') ? fileType.startsWith(token.slice(0, -1)) : fileType === token,\n );\n};\n\nconst detectDragReject = (dataTransfer?: DataTransfer | null) => {\n if (!canDetectDragReject.value || !dataTransfer) {\n return false;\n }\n\n const items = dataTransfer.items;\n\n if (!items || items.length === 0) {\n return false;\n }\n\n const fileItems = Array.from(items).filter((item) => item.kind === 'file');\n\n if (fileItems.length === 0) {\n return false;\n }\n\n return fileItems.every((item) => !matchesAcceptType(item.type));\n};\n\nconst resolveState = (file: File, index: number) => {\n const source = props.uploadState;\n\n if (!source) {\n return undefined;\n }\n\n return typeof source === 'function' ? source(file, index) : source.get(file);\n};\n\nconst resolvedStates = computed(() => props.modelValue.map(resolveState));\n\nconst canUseObjectUrl = () =>\n typeof window !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function' &&\n typeof URL.revokeObjectURL === 'function';\n\nconst isThumbnailable = (file: File) =>\n file.type.startsWith('image/') &&\n (props.maxThumbnailSize === null || file.size <= props.maxThumbnailSize);\n\nconst releaseAllThumbnails = () => {\n if (canUseObjectUrl()) {\n for (const entry of thumbs.value.values()) {\n URL.revokeObjectURL(entry.url);\n }\n }\n\n thumbs.value.clear();\n};\n\nconst syncThumbnails = () => {\n if (!props.thumbnails || !canUseObjectUrl()) {\n releaseAllThumbnails();\n return;\n }\n\n const live = new Set(props.modelValue);\n\n for (const [file, entry] of [...thumbs.value]) {\n if (!live.has(file)) {\n URL.revokeObjectURL(entry.url);\n thumbs.value.delete(file);\n }\n }\n\n for (const file of props.modelValue) {\n if (thumbs.value.has(file) || !isThumbnailable(file)) {\n continue;\n }\n\n const url = URL.createObjectURL(file);\n thumbs.value.set(file, { url, width: null, height: null });\n\n const image = new Image();\n\n image.onload = () => {\n const current = thumbs.value.get(file);\n\n if (!current || current.url !== url) {\n return;\n }\n\n thumbs.value.set(file, { url, width: image.naturalWidth, height: image.naturalHeight });\n };\n\n image.src = url;\n }\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isDisabled.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isDisabled.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst emitCancel = (file: File, index: number, reason: 'remove' | 'clear') => {\n const state = resolvedStates.value[index];\n\n if (!state || (state.status !== 'uploading' && state.status !== 'paused')) {\n return;\n }\n\n emit('cancel', { file, index, fileKey: fileKeyFor(file), state, reason });\n};\n\nconst removeFile = (index: number) => {\n if (isDisabled.value) {\n return;\n }\n\n const file = props.modelValue[index];\n\n if (file) {\n emitCancel(file, index, 'remove');\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isDisabled.value || !hasFiles.value) {\n return;\n }\n\n props.modelValue.forEach((file, index) => {\n emitCancel(file, index, 'clear');\n });\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst rows = computed<FileRow[]>(() =>\n props.modelValue.map((file, index) => {\n const state = resolvedStates.value[index];\n const status = state?.status;\n const thumb = thumbs.value.get(file);\n const key = fileKeyFor(file);\n\n const progress = state?.progress;\n const percent = typeof progress === 'number' ? Math.round(progress) : undefined;\n const isResumable = state?.resumable === true;\n\n const canRetry = Boolean(\n state && (status === 'error' || status === 'paused') && state.retryable !== false,\n );\n const retryMode: 'resume' | 'restart' = isResumable ? 'resume' : 'restart';\n\n const sizeLabel = formatFileSize(file.size);\n const typeLabel = formatFileType(file);\n const dimensionsLabel = thumb && thumb.width !== null && thumb.height !== null\n ? `${thumb.width} × ${thumb.height}`\n : undefined;\n\n const progressLabel = status === 'uploading' && percent !== undefined\n ? `${percent}%`\n : undefined;\n\n let remainingLabel: string | undefined;\n\n if (status === 'uploading') {\n if (state?.remainingLabel) {\n remainingLabel = state.remainingLabel;\n } else if (\n typeof state?.remainingMs === 'number' &&\n Number.isFinite(state.remainingMs) &&\n state.remainingMs >= 0\n ) {\n remainingLabel = props.remainingTimeFormat(state.remainingMs);\n }\n }\n\n const uploadedLabel = (status === 'error' || status === 'paused') &&\n typeof state?.uploadedBytes === 'number'\n ? `${formatFileSize(state.uploadedBytes)} of ${sizeLabel} uploaded`\n : undefined;\n\n const statusLabel = status ? resolvedStatusLabels.value[status] : undefined;\n\n let actionLabel: string | undefined;\n\n if (canRetry) {\n actionLabel = isResumable && percent !== undefined\n ? props.resumeLabel(percent)\n : props.retryLabel;\n }\n\n const errorText = status === 'error' ? state?.error : undefined;\n\n const slotProps: TFileUploadFileSlotProps = {\n file,\n index,\n fileKey: key,\n state,\n thumbnailUrl: state?.thumbnailUrl ?? thumb?.url,\n sizeLabel,\n typeLabel,\n dimensionsLabel,\n progressLabel,\n remainingLabel,\n uploadedLabel,\n statusLabel,\n actionLabel,\n canRetry,\n retryMode,\n isDragActive: isDragActive.value,\n isDragReject: isDragReject.value,\n removeFile: () => removeFile(index),\n retryFile: () => {\n if (!canRetry || isDisabled.value || !state) {\n return;\n }\n\n emit('retry', { file, index, fileKey: key, mode: retryMode, state });\n },\n };\n\n return {\n slotProps,\n rowClass: status ? `is-${status}` : undefined,\n metaText: [typeLabel, sizeLabel, dimensionsLabel, state?.meta].filter(Boolean).join(' · '),\n statusText: [statusLabel, progressLabel, remainingLabel, uploadedLabel]\n .filter(Boolean)\n .join(' · '),\n errorText,\n errorId: errorText ? `${key}-error` : undefined,\n showProgress: Boolean(\n state &&\n (status === 'uploading' ||\n status === 'paused' ||\n status === 'error' ||\n (status === 'success' && percent !== undefined)),\n ),\n progressValue: status === 'uploading' ? progress : (progress ?? 0),\n progressAriaLabel: `${statusLabel ?? ''} ${file.name}`.trim(),\n isBusy: status === 'uploading',\n isSuccess: status === 'success',\n };\n }),\n);\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isDisabled.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onRetryFocus = (key: string) => {\n retryFocusKey.value = key;\n};\n\nconst onRetryBlur = (key: string) => {\n if (retryFocusKey.value === key) {\n retryFocusKey.value = null;\n }\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isDisabled.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n isDragReject.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n isDragReject.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nconst previousStatuses = new Map<File, TFileUploadStatus>();\n\nwatch(\n resolvedStates,\n (states) => {\n const messages: string[] = [];\n const live = new Set<File>();\n\n props.modelValue.forEach((file, index) => {\n live.add(file);\n\n const status = states[index]?.status;\n\n if (!status) {\n return;\n }\n\n const previous = previousStatuses.get(file);\n\n if (previous === status) {\n return;\n }\n\n previousStatuses.set(file, status);\n\n if (previous === undefined) {\n return;\n }\n\n if (status === 'success') {\n messages.push(`${file.name} uploaded.`);\n } else if (status === 'error') {\n messages.push(`${file.name} failed. ${states[index]?.error ?? ''}`.trim());\n }\n });\n\n for (const file of [...previousStatuses.keys()]) {\n if (!live.has(file)) {\n previousStatuses.delete(file);\n }\n }\n\n if (messages.length > 0) {\n // Append instead of overwriting a single string: two identical messages\n // in a row (a retry that fails with the same error) would otherwise write\n // a byte-identical value, which `Object.is` suppresses — no DOM mutation,\n // so `aria-live` never fires and the repeat failure is announced as silence.\n statusLogId += 1;\n statusLog.value = [\n ...statusLog.value,\n { id: statusLogId, text: messages.join(' ') },\n ].slice(-STATUS_LOG_LIMIT);\n }\n },\n { flush: 'post', immediate: true },\n);\n\nwatch(\n resolvedStates,\n () => {\n const key = retryFocusKey.value;\n const root = rootRef.value;\n\n if (!key || !root) {\n return;\n }\n\n if (document.activeElement && document.activeElement !== document.body) {\n return;\n }\n\n if (root.querySelector(`[data-t-retry=\"${key}\"]`)) {\n return;\n }\n\n retryFocusKey.value = null;\n\n const fallback = root.querySelector<HTMLElement>(`[data-t-remove=\"${key}\"]`) ??\n root.querySelector<HTMLElement>('.t-file-upload__dropzone');\n\n fallback?.focus();\n },\n { flush: 'post' },\n);\n\nwatch(() => props.modelValue, syncThumbnails);\nwatch(() => props.thumbnails, syncThumbnails);\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n syncThumbnails();\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n releaseAllThumbnails();\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isDisabled\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isDisabled || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isDisabled ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :is-drag-reject=\"isDragReject\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ dropzoneLabel }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n class=\"t-file-upload__status-log t-visually-hidden\"\n role=\"log\"\n aria-live=\"polite\"\n >\n <p\n v-for=\"entry in statusLog\"\n :key=\"entry.id\"\n class=\"t-file-upload__status-log-entry\"\n >\n {{ entry.text }}\n </p>\n </div>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isDisabled\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"row in rows\"\n :key=\"row.slotProps.fileKey\"\n class=\"t-file-upload__file\"\n :class=\"row.rowClass\"\n :aria-busy=\"row.isBusy || undefined\"\n >\n <slot\n name=\"file\"\n v-bind=\"row.slotProps\"\n >\n <span\n class=\"t-file-upload__thumb\"\n aria-hidden=\"true\"\n >\n <img\n v-if=\"row.slotProps.thumbnailUrl\"\n class=\"t-file-upload__thumb-image\"\n :src=\"row.slotProps.thumbnailUrl\"\n alt=\"\"\n >\n <span\n v-else\n class=\"t-file-upload__thumb-fallback\"\n >\n {{ row.slotProps.typeLabel }}\n </span>\n </span>\n\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ row.slotProps.file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ row.metaText }}\n </p>\n\n <slot\n name=\"file-status\"\n v-bind=\"row.slotProps\"\n >\n <div\n v-if=\"row.showProgress\"\n class=\"t-file-upload__file-progress\"\n >\n <TProgress\n size=\"sm\"\n :value=\"row.progressValue\"\n :label=\"row.progressAriaLabel\"\n />\n </div>\n\n <p\n v-if=\"row.statusText\"\n class=\"t-file-upload__file-status\"\n >\n <span\n v-if=\"row.isSuccess\"\n class=\"t-file-upload__proof\"\n aria-hidden=\"true\"\n >✓</span>{{ row.statusText }}\n </p>\n\n <p\n v-if=\"row.errorText\"\n :id=\"row.errorId\"\n class=\"t-file-upload__file-error\"\n >\n {{ row.errorText }}\n </p>\n </slot>\n </div>\n\n <button\n v-if=\"row.slotProps.canRetry\"\n class=\"t-file-upload__retry\"\n type=\"button\"\n :data-t-retry=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-describedby=\"row.errorId\"\n @focus=\"onRetryFocus(row.slotProps.fileKey)\"\n @blur=\"onRetryBlur(row.slotProps.fileKey)\"\n @click=\"row.slotProps.retryFile()\"\n >\n {{ row.slotProps.actionLabel }}\n </button>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :data-t-remove=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-label=\"`Remove ${row.slotProps.file.name}`\"\n @click=\"row.slotProps.removeFile()\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TFileUpload.js","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: {\n file: File;\n index: number;\n removeFile: () => void;\n }) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isUnavailable = computed(() => props.disabled || props.loading);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isUnavailable.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst fileKey = (file: File, index: number) =>\n `${file.name}-${file.size}-${file.lastModified}-${index}`;\n\nconst matchesAccept = (file: File) => {\n if (!props.accept.trim()) {\n return true;\n }\n\n const tokens = props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean);\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return tokens.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isUnavailable.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isUnavailable.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst removeFile = (index: number) => {\n if (isUnavailable.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isUnavailable.value || !hasFiles.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isUnavailable.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isUnavailable.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isUnavailable\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isUnavailable || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isUnavailable ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ label }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"(file, index) in modelValue\"\n :key=\"fileKey(file, index)\"\n class=\"t-file-upload__file\"\n >\n <slot\n name=\"file\"\n :file=\"file\"\n :index=\"index\"\n :remove-file=\"() => removeFile(index)\"\n >\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ formatFileSize(file.size) }}\n </p>\n </div>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n :aria-label=\"`Remove ${file.name}`\"\n @click=\"removeFile(index)\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"TFileUpload.js","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TProgress from './TProgress.vue';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nexport type TFileUploadStatus =\n | 'pending'\n | 'uploading'\n | 'paused'\n | 'error'\n | 'success';\n\nexport interface TFileUploadState {\n status: TFileUploadStatus;\n progress?: number;\n uploadedBytes?: number;\n remainingMs?: number;\n remainingLabel?: string;\n error?: string;\n resumable?: boolean;\n retryable?: boolean;\n thumbnailUrl?: string;\n meta?: string;\n}\n\nexport type TFileUploadStateSource =\n | ReadonlyMap<File, TFileUploadState>\n | ((file: File, index: number) => TFileUploadState | undefined);\n\nexport interface TFileUploadRetryPayload {\n file: File;\n index: number;\n fileKey: string;\n mode: 'resume' | 'restart';\n state: TFileUploadState;\n}\n\nexport interface TFileUploadCancelPayload {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState;\n reason: 'remove' | 'clear';\n}\n\nexport interface TFileUploadFileSlotProps {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState | undefined;\n thumbnailUrl: string | undefined;\n sizeLabel: string;\n typeLabel: string;\n dimensionsLabel: string | undefined;\n progressLabel: string | undefined;\n remainingLabel: string | undefined;\n uploadedLabel: string | undefined;\n statusLabel: string | undefined;\n actionLabel: string | undefined;\n canRetry: boolean;\n retryMode: 'resume' | 'restart';\n isDragActive: boolean;\n isDragReject: boolean;\n removeFile: () => void;\n retryFile: () => void;\n}\n\ninterface ThumbEntry {\n url: string;\n width: number | null;\n height: number | null;\n}\n\ninterface FileRow {\n slotProps: TFileUploadFileSlotProps;\n rowClass: string | undefined;\n metaText: string;\n statusText: string;\n errorText: string | undefined;\n errorId: string | undefined;\n showProgress: boolean;\n progressValue: number | undefined;\n progressAriaLabel: string;\n isBusy: boolean;\n isSuccess: boolean;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<TFileUploadStatus, string> = {\n pending: 'Waiting',\n uploading: 'Uploading',\n paused: 'Paused',\n error: 'Upload failed',\n success: 'Uploaded',\n};\n\n// The status log keeps a bounded history of announcements. Old entries are\n// trimmed rather than replaced in place so that each terminal transition\n// inserts a brand new node: `aria-relevant` defaults to \"additions text\", so\n// assistive tech announces the insertion and ignores the trim.\nconst STATUS_LOG_LIMIT = 5;\n\ntype StatusLogEntry = {\n id: number;\n text: string;\n};\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n isDragReject: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: TFileUploadFileSlotProps) => unknown;\n 'file-status'?: (props: TFileUploadFileSlotProps) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n uploadState?: TFileUploadStateSource;\n dragLabel?: string;\n dragRejectLabel?: string;\n thumbnails?: boolean;\n maxThumbnailSize?: number | null;\n retryLabel?: string;\n resumeLabel?: (percent: number) => string;\n remainingTimeFormat?: (remainingMs: number) => string;\n statusLabels?: Partial<Record<TFileUploadStatus, string>>;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n uploadState: undefined,\n dragLabel: 'Release to upload',\n dragRejectLabel: 'This file type is not accepted',\n thumbnails: true,\n maxThumbnailSize: 10 * 1024 * 1024,\n retryLabel: 'Retry',\n resumeLabel: (percent: number) => `Resume from ${percent}%`,\n remainingTimeFormat: (remainingMs: number) => {\n if (remainingMs < 5000) {\n return 'Less than 5s left';\n }\n\n const seconds = Math.round(remainingMs / 1000);\n\n if (seconds < 60) {\n return `About ${seconds}s left`;\n }\n\n const minutes = Math.round(seconds / 60);\n\n if (minutes < 60) {\n return `About ${minutes} min left`;\n }\n\n return `About ${Math.round(minutes / 60)}h left`;\n },\n statusLabels: () => ({}),\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n retry: [payload: TFileUploadRetryPayload];\n cancel: [payload: TFileUploadCancelPayload];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isDragReject = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\nconst statusLog = ref<StatusLogEntry[]>([]);\nlet statusLogId = 0;\nconst retryFocusKey = ref<string | null>(null);\nconst thumbs = ref(new Map<File, ThumbEntry>());\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isDisabled = computed(() => props.disabled);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst resolvedStatusLabels = computed(() => ({\n ...DEFAULT_STATUS_LABELS,\n ...props.statusLabels,\n}));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isDisabled.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'is-drag-reject': isDragReject.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst dropzoneLabel = computed(() => {\n if (isDragReject.value) {\n return props.dragRejectLabel;\n }\n\n return isDragActive.value ? props.dragLabel : props.label;\n});\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst formatFileType = (file: File) => {\n const dotIndex = file.name.lastIndexOf('.');\n const extension = dotIndex > 0 && dotIndex < file.name.length - 1\n ? file.name.slice(dotIndex + 1).toUpperCase()\n : '';\n\n if (extension && extension.length <= 5) {\n return extension;\n }\n\n const subtype = file.type.includes('/') ? file.type.split('/')[1] : '';\n\n return subtype ? subtype.toUpperCase() : 'FILE';\n};\n\nlet keySeq = 0;\nconst keyCache = new WeakMap<File, string>();\n\nconst fileKeyFor = (file: File) => {\n let key = keyCache.get(file);\n\n if (!key) {\n keySeq += 1;\n key = `${uploadId}-f${keySeq}`;\n keyCache.set(file, key);\n }\n\n return key;\n};\n\nconst acceptTokens = computed(() =>\n props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean),\n);\n\nconst matchesAccept = (file: File) => {\n if (acceptTokens.value.length === 0) {\n return true;\n }\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return acceptTokens.value.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst canDetectDragReject = computed(() =>\n acceptTokens.value.length > 0 && acceptTokens.value.every((token) => token.includes('/')),\n);\n\nconst matchesAcceptType = (type: string) => {\n const fileType = type.toLowerCase();\n\n if (!fileType) {\n return true;\n }\n\n return acceptTokens.value.some((token) =>\n token.endsWith('/*') ? fileType.startsWith(token.slice(0, -1)) : fileType === token,\n );\n};\n\nconst detectDragReject = (dataTransfer?: DataTransfer | null) => {\n if (!canDetectDragReject.value || !dataTransfer) {\n return false;\n }\n\n const items = dataTransfer.items;\n\n if (!items || items.length === 0) {\n return false;\n }\n\n const fileItems = Array.from(items).filter((item) => item.kind === 'file');\n\n if (fileItems.length === 0) {\n return false;\n }\n\n return fileItems.every((item) => !matchesAcceptType(item.type));\n};\n\nconst resolveState = (file: File, index: number) => {\n const source = props.uploadState;\n\n if (!source) {\n return undefined;\n }\n\n return typeof source === 'function' ? source(file, index) : source.get(file);\n};\n\nconst resolvedStates = computed(() => props.modelValue.map(resolveState));\n\nconst canUseObjectUrl = () =>\n typeof window !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function' &&\n typeof URL.revokeObjectURL === 'function';\n\nconst isThumbnailable = (file: File) =>\n file.type.startsWith('image/') &&\n (props.maxThumbnailSize === null || file.size <= props.maxThumbnailSize);\n\nconst releaseAllThumbnails = () => {\n if (canUseObjectUrl()) {\n for (const entry of thumbs.value.values()) {\n URL.revokeObjectURL(entry.url);\n }\n }\n\n thumbs.value.clear();\n};\n\nconst syncThumbnails = () => {\n if (!props.thumbnails || !canUseObjectUrl()) {\n releaseAllThumbnails();\n return;\n }\n\n const live = new Set(props.modelValue);\n\n for (const [file, entry] of [...thumbs.value]) {\n if (!live.has(file)) {\n URL.revokeObjectURL(entry.url);\n thumbs.value.delete(file);\n }\n }\n\n for (const file of props.modelValue) {\n if (thumbs.value.has(file) || !isThumbnailable(file)) {\n continue;\n }\n\n const url = URL.createObjectURL(file);\n thumbs.value.set(file, { url, width: null, height: null });\n\n const image = new Image();\n\n image.onload = () => {\n const current = thumbs.value.get(file);\n\n if (!current || current.url !== url) {\n return;\n }\n\n thumbs.value.set(file, { url, width: image.naturalWidth, height: image.naturalHeight });\n };\n\n image.src = url;\n }\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isDisabled.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isDisabled.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst emitCancel = (file: File, index: number, reason: 'remove' | 'clear') => {\n const state = resolvedStates.value[index];\n\n if (!state || (state.status !== 'uploading' && state.status !== 'paused')) {\n return;\n }\n\n emit('cancel', { file, index, fileKey: fileKeyFor(file), state, reason });\n};\n\nconst removeFile = (index: number) => {\n if (isDisabled.value) {\n return;\n }\n\n const file = props.modelValue[index];\n\n if (file) {\n emitCancel(file, index, 'remove');\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isDisabled.value || !hasFiles.value) {\n return;\n }\n\n props.modelValue.forEach((file, index) => {\n emitCancel(file, index, 'clear');\n });\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst rows = computed<FileRow[]>(() =>\n props.modelValue.map((file, index) => {\n const state = resolvedStates.value[index];\n const status = state?.status;\n const thumb = thumbs.value.get(file);\n const key = fileKeyFor(file);\n\n const progress = state?.progress;\n const percent = typeof progress === 'number' ? Math.round(progress) : undefined;\n const isResumable = state?.resumable === true;\n\n const canRetry = Boolean(\n state && (status === 'error' || status === 'paused') && state.retryable !== false,\n );\n const retryMode: 'resume' | 'restart' = isResumable ? 'resume' : 'restart';\n\n const sizeLabel = formatFileSize(file.size);\n const typeLabel = formatFileType(file);\n const dimensionsLabel = thumb && thumb.width !== null && thumb.height !== null\n ? `${thumb.width} × ${thumb.height}`\n : undefined;\n\n const progressLabel = status === 'uploading' && percent !== undefined\n ? `${percent}%`\n : undefined;\n\n let remainingLabel: string | undefined;\n\n if (status === 'uploading') {\n if (state?.remainingLabel) {\n remainingLabel = state.remainingLabel;\n } else if (\n typeof state?.remainingMs === 'number' &&\n Number.isFinite(state.remainingMs) &&\n state.remainingMs >= 0\n ) {\n remainingLabel = props.remainingTimeFormat(state.remainingMs);\n }\n }\n\n const uploadedLabel = (status === 'error' || status === 'paused') &&\n typeof state?.uploadedBytes === 'number'\n ? `${formatFileSize(state.uploadedBytes)} of ${sizeLabel} uploaded`\n : undefined;\n\n const statusLabel = status ? resolvedStatusLabels.value[status] : undefined;\n\n let actionLabel: string | undefined;\n\n if (canRetry) {\n actionLabel = isResumable && percent !== undefined\n ? props.resumeLabel(percent)\n : props.retryLabel;\n }\n\n const errorText = status === 'error' ? state?.error : undefined;\n\n const slotProps: TFileUploadFileSlotProps = {\n file,\n index,\n fileKey: key,\n state,\n thumbnailUrl: state?.thumbnailUrl ?? thumb?.url,\n sizeLabel,\n typeLabel,\n dimensionsLabel,\n progressLabel,\n remainingLabel,\n uploadedLabel,\n statusLabel,\n actionLabel,\n canRetry,\n retryMode,\n isDragActive: isDragActive.value,\n isDragReject: isDragReject.value,\n removeFile: () => removeFile(index),\n retryFile: () => {\n if (!canRetry || isDisabled.value || !state) {\n return;\n }\n\n emit('retry', { file, index, fileKey: key, mode: retryMode, state });\n },\n };\n\n return {\n slotProps,\n rowClass: status ? `is-${status}` : undefined,\n metaText: [typeLabel, sizeLabel, dimensionsLabel, state?.meta].filter(Boolean).join(' · '),\n statusText: [statusLabel, progressLabel, remainingLabel, uploadedLabel]\n .filter(Boolean)\n .join(' · '),\n errorText,\n errorId: errorText ? `${key}-error` : undefined,\n showProgress: Boolean(\n state &&\n (status === 'uploading' ||\n status === 'paused' ||\n status === 'error' ||\n (status === 'success' && percent !== undefined)),\n ),\n progressValue: status === 'uploading' ? progress : (progress ?? 0),\n progressAriaLabel: `${statusLabel ?? ''} ${file.name}`.trim(),\n isBusy: status === 'uploading',\n isSuccess: status === 'success',\n };\n }),\n);\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isDisabled.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onRetryFocus = (key: string) => {\n retryFocusKey.value = key;\n};\n\nconst onRetryBlur = (key: string) => {\n if (retryFocusKey.value === key) {\n retryFocusKey.value = null;\n }\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isDisabled.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n isDragReject.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n isDragReject.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nconst previousStatuses = new Map<File, TFileUploadStatus>();\n\nwatch(\n resolvedStates,\n (states) => {\n const messages: string[] = [];\n const live = new Set<File>();\n\n props.modelValue.forEach((file, index) => {\n live.add(file);\n\n const status = states[index]?.status;\n\n if (!status) {\n return;\n }\n\n const previous = previousStatuses.get(file);\n\n if (previous === status) {\n return;\n }\n\n previousStatuses.set(file, status);\n\n if (previous === undefined) {\n return;\n }\n\n if (status === 'success') {\n messages.push(`${file.name} uploaded.`);\n } else if (status === 'error') {\n messages.push(`${file.name} failed. ${states[index]?.error ?? ''}`.trim());\n }\n });\n\n for (const file of [...previousStatuses.keys()]) {\n if (!live.has(file)) {\n previousStatuses.delete(file);\n }\n }\n\n if (messages.length > 0) {\n // Append instead of overwriting a single string: two identical messages\n // in a row (a retry that fails with the same error) would otherwise write\n // a byte-identical value, which `Object.is` suppresses — no DOM mutation,\n // so `aria-live` never fires and the repeat failure is announced as silence.\n statusLogId += 1;\n statusLog.value = [\n ...statusLog.value,\n { id: statusLogId, text: messages.join(' ') },\n ].slice(-STATUS_LOG_LIMIT);\n }\n },\n { flush: 'post', immediate: true },\n);\n\nwatch(\n resolvedStates,\n () => {\n const key = retryFocusKey.value;\n const root = rootRef.value;\n\n if (!key || !root) {\n return;\n }\n\n if (document.activeElement && document.activeElement !== document.body) {\n return;\n }\n\n if (root.querySelector(`[data-t-retry=\"${key}\"]`)) {\n return;\n }\n\n retryFocusKey.value = null;\n\n const fallback = root.querySelector<HTMLElement>(`[data-t-remove=\"${key}\"]`) ??\n root.querySelector<HTMLElement>('.t-file-upload__dropzone');\n\n fallback?.focus();\n },\n { flush: 'post' },\n);\n\nwatch(() => props.modelValue, syncThumbnails);\nwatch(() => props.thumbnails, syncThumbnails);\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n syncThumbnails();\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n releaseAllThumbnails();\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isDisabled\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isDisabled || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isDisabled ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :is-drag-reject=\"isDragReject\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ dropzoneLabel }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n class=\"t-file-upload__status-log t-visually-hidden\"\n role=\"log\"\n aria-live=\"polite\"\n >\n <p\n v-for=\"entry in statusLog\"\n :key=\"entry.id\"\n class=\"t-file-upload__status-log-entry\"\n >\n {{ entry.text }}\n </p>\n </div>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isDisabled\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"row in rows\"\n :key=\"row.slotProps.fileKey\"\n class=\"t-file-upload__file\"\n :class=\"row.rowClass\"\n :aria-busy=\"row.isBusy || undefined\"\n >\n <slot\n name=\"file\"\n v-bind=\"row.slotProps\"\n >\n <span\n class=\"t-file-upload__thumb\"\n aria-hidden=\"true\"\n >\n <img\n v-if=\"row.slotProps.thumbnailUrl\"\n class=\"t-file-upload__thumb-image\"\n :src=\"row.slotProps.thumbnailUrl\"\n alt=\"\"\n >\n <span\n v-else\n class=\"t-file-upload__thumb-fallback\"\n >\n {{ row.slotProps.typeLabel }}\n </span>\n </span>\n\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ row.slotProps.file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ row.metaText }}\n </p>\n\n <slot\n name=\"file-status\"\n v-bind=\"row.slotProps\"\n >\n <div\n v-if=\"row.showProgress\"\n class=\"t-file-upload__file-progress\"\n >\n <TProgress\n size=\"sm\"\n :value=\"row.progressValue\"\n :label=\"row.progressAriaLabel\"\n />\n </div>\n\n <p\n v-if=\"row.statusText\"\n class=\"t-file-upload__file-status\"\n >\n <span\n v-if=\"row.isSuccess\"\n class=\"t-file-upload__proof\"\n aria-hidden=\"true\"\n >✓</span>{{ row.statusText }}\n </p>\n\n <p\n v-if=\"row.errorText\"\n :id=\"row.errorId\"\n class=\"t-file-upload__file-error\"\n >\n {{ row.errorText }}\n </p>\n </slot>\n </div>\n\n <button\n v-if=\"row.slotProps.canRetry\"\n class=\"t-file-upload__retry\"\n type=\"button\"\n :data-t-retry=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-describedby=\"row.errorId\"\n @focus=\"onRetryFocus(row.slotProps.fileKey)\"\n @blur=\"onRetryBlur(row.slotProps.fileKey)\"\n @click=\"row.slotProps.retryFile()\"\n >\n {{ row.slotProps.actionLabel }}\n </button>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :data-t-remove=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-label=\"`Remove ${row.slotProps.file.name}`\"\n @click=\"row.slotProps.removeFile()\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
@@ -5,6 +5,55 @@ export interface TFileUploadRejection {
|
|
|
5
5
|
reason: TFileUploadRejectionReason;
|
|
6
6
|
message: string;
|
|
7
7
|
}
|
|
8
|
+
export type TFileUploadStatus = 'pending' | 'uploading' | 'paused' | 'error' | 'success';
|
|
9
|
+
export interface TFileUploadState {
|
|
10
|
+
status: TFileUploadStatus;
|
|
11
|
+
progress?: number;
|
|
12
|
+
uploadedBytes?: number;
|
|
13
|
+
remainingMs?: number;
|
|
14
|
+
remainingLabel?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
resumable?: boolean;
|
|
17
|
+
retryable?: boolean;
|
|
18
|
+
thumbnailUrl?: string;
|
|
19
|
+
meta?: string;
|
|
20
|
+
}
|
|
21
|
+
export type TFileUploadStateSource = ReadonlyMap<File, TFileUploadState> | ((file: File, index: number) => TFileUploadState | undefined);
|
|
22
|
+
export interface TFileUploadRetryPayload {
|
|
23
|
+
file: File;
|
|
24
|
+
index: number;
|
|
25
|
+
fileKey: string;
|
|
26
|
+
mode: 'resume' | 'restart';
|
|
27
|
+
state: TFileUploadState;
|
|
28
|
+
}
|
|
29
|
+
export interface TFileUploadCancelPayload {
|
|
30
|
+
file: File;
|
|
31
|
+
index: number;
|
|
32
|
+
fileKey: string;
|
|
33
|
+
state: TFileUploadState;
|
|
34
|
+
reason: 'remove' | 'clear';
|
|
35
|
+
}
|
|
36
|
+
export interface TFileUploadFileSlotProps {
|
|
37
|
+
file: File;
|
|
38
|
+
index: number;
|
|
39
|
+
fileKey: string;
|
|
40
|
+
state: TFileUploadState | undefined;
|
|
41
|
+
thumbnailUrl: string | undefined;
|
|
42
|
+
sizeLabel: string;
|
|
43
|
+
typeLabel: string;
|
|
44
|
+
dimensionsLabel: string | undefined;
|
|
45
|
+
progressLabel: string | undefined;
|
|
46
|
+
remainingLabel: string | undefined;
|
|
47
|
+
uploadedLabel: string | undefined;
|
|
48
|
+
statusLabel: string | undefined;
|
|
49
|
+
actionLabel: string | undefined;
|
|
50
|
+
canRetry: boolean;
|
|
51
|
+
retryMode: 'resume' | 'restart';
|
|
52
|
+
isDragActive: boolean;
|
|
53
|
+
isDragReject: boolean;
|
|
54
|
+
removeFile: () => void;
|
|
55
|
+
retryFile: () => void;
|
|
56
|
+
}
|
|
8
57
|
type __VLS_Props = {
|
|
9
58
|
modelValue?: File[];
|
|
10
59
|
size?: TSize;
|
|
@@ -22,6 +71,15 @@ type __VLS_Props = {
|
|
|
22
71
|
paste?: boolean;
|
|
23
72
|
drop?: boolean;
|
|
24
73
|
showFileList?: boolean;
|
|
74
|
+
uploadState?: TFileUploadStateSource;
|
|
75
|
+
dragLabel?: string;
|
|
76
|
+
dragRejectLabel?: string;
|
|
77
|
+
thumbnails?: boolean;
|
|
78
|
+
maxThumbnailSize?: number | null;
|
|
79
|
+
retryLabel?: string;
|
|
80
|
+
resumeLabel?: (percent: number) => string;
|
|
81
|
+
remainingTimeFormat?: (remainingMs: number) => string;
|
|
82
|
+
statusLabels?: Partial<Record<TFileUploadStatus, string>>;
|
|
25
83
|
};
|
|
26
84
|
declare function __VLS_template(): {
|
|
27
85
|
attrs: Partial<{}>;
|
|
@@ -30,14 +88,12 @@ declare function __VLS_template(): {
|
|
|
30
88
|
default?: (props: {
|
|
31
89
|
files: File[];
|
|
32
90
|
isDragActive: boolean;
|
|
91
|
+
isDragReject: boolean;
|
|
33
92
|
openFileDialog: () => void;
|
|
34
93
|
clearFiles: () => void;
|
|
35
94
|
}) => unknown;
|
|
36
|
-
file?: (props:
|
|
37
|
-
|
|
38
|
-
index: number;
|
|
39
|
-
removeFile: () => void;
|
|
40
|
-
}) => unknown;
|
|
95
|
+
file?: (props: TFileUploadFileSlotProps) => unknown;
|
|
96
|
+
'file-status'?: (props: TFileUploadFileSlotProps) => unknown;
|
|
41
97
|
actions?: (props: {
|
|
42
98
|
files: File[];
|
|
43
99
|
openFileDialog: () => void;
|
|
@@ -48,14 +104,12 @@ declare function __VLS_template(): {
|
|
|
48
104
|
default?: (props: {
|
|
49
105
|
files: File[];
|
|
50
106
|
isDragActive: boolean;
|
|
107
|
+
isDragReject: boolean;
|
|
51
108
|
openFileDialog: () => void;
|
|
52
109
|
clearFiles: () => void;
|
|
53
110
|
}) => unknown;
|
|
54
|
-
file?: (props:
|
|
55
|
-
|
|
56
|
-
index: number;
|
|
57
|
-
removeFile: () => void;
|
|
58
|
-
}) => unknown;
|
|
111
|
+
file?: (props: TFileUploadFileSlotProps) => unknown;
|
|
112
|
+
'file-status'?: (props: TFileUploadFileSlotProps) => unknown;
|
|
59
113
|
actions?: (props: {
|
|
60
114
|
files: File[];
|
|
61
115
|
openFileDialog: () => void;
|
|
@@ -71,12 +125,16 @@ declare function __VLS_template(): {
|
|
|
71
125
|
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
72
126
|
declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
73
127
|
"update:modelValue": (files: File[]) => any;
|
|
128
|
+
cancel: (payload: TFileUploadCancelPayload) => any;
|
|
74
129
|
"files-accepted": (files: File[]) => any;
|
|
75
130
|
"files-rejected": (rejections: TFileUploadRejection[]) => any;
|
|
131
|
+
retry: (payload: TFileUploadRetryPayload) => any;
|
|
76
132
|
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
77
133
|
"onUpdate:modelValue"?: ((files: File[]) => any) | undefined;
|
|
134
|
+
onCancel?: ((payload: TFileUploadCancelPayload) => any) | undefined;
|
|
78
135
|
"onFiles-accepted"?: ((files: File[]) => any) | undefined;
|
|
79
136
|
"onFiles-rejected"?: ((rejections: TFileUploadRejection[]) => any) | undefined;
|
|
137
|
+
onRetry?: ((payload: TFileUploadRetryPayload) => any) | undefined;
|
|
80
138
|
}>, {
|
|
81
139
|
multiple: boolean;
|
|
82
140
|
modelValue: File[];
|
|
@@ -94,6 +152,15 @@ declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}
|
|
|
94
152
|
buttonLabel: string;
|
|
95
153
|
filesLabel: string;
|
|
96
154
|
showFileList: boolean;
|
|
155
|
+
uploadState: TFileUploadStateSource;
|
|
156
|
+
dragLabel: string;
|
|
157
|
+
dragRejectLabel: string;
|
|
158
|
+
thumbnails: boolean;
|
|
159
|
+
maxThumbnailSize: number | null;
|
|
160
|
+
retryLabel: string;
|
|
161
|
+
resumeLabel: (percent: number) => string;
|
|
162
|
+
remainingTimeFormat: (remainingMs: number) => string;
|
|
163
|
+
statusLabels: Partial<Record<TFileUploadStatus, string>>;
|
|
97
164
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
|
|
98
165
|
rootRef: HTMLDivElement;
|
|
99
166
|
inputRef: HTMLInputElement;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TFileUpload.vue.d.ts","sourceRoot":"","sources":["../../src/components/TFileUpload.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TFileUpload.vue.d.ts","sourceRoot":"","sources":["../../src/components/TFileUpload.vue"],"names":[],"mappings":"AA6sCA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAQhD,MAAM,MAAM,0BAA0B,GAClC,mBAAmB,GACnB,gBAAgB,GAChB,gBAAgB,CAAC;AAErB,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,0BAA0B,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,iBAAiB,GACzB,SAAS,GACT,WAAW,GACX,QAAQ,GACR,OAAO,GACP,SAAS,CAAC;AAEd,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,sBAAsB,GAC9B,WAAW,CAAC,IAAI,EAAE,gBAAgB,CAAC,GACnC,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,gBAAgB,GAAG,SAAS,CAAC,CAAC;AAElE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACpC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,QAAQ,GAAG,SAAS,CAAC;IAChC,YAAY,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AA2DD,KAAK,WAAW,GAAG;IACf,UAAU,CAAC,EAAE,IAAI,EAAE,CAAC;IACpB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,sBAAsB,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1C,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACtD,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC,CAAC;CAC3D,CAAC;AA61BJ,iBAAS,cAAc;WAwRT,OAAO,IAA6B;;eAhqCzC,MAAM,OAAO;kBACV,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,YAAY,EAAE,OAAO,CAAC;YACtB,YAAY,EAAE,OAAO,CAAC;YACtB,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;eACN,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;wBACnC,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;kBAClD,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;;eAdN,MAAM,OAAO;kBACV,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,YAAY,EAAE,OAAO,CAAC;YACtB,YAAY,EAAE,OAAO,CAAC;YACtB,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;eACN,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;wBACnC,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;kBAClD,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;;;;;;;EAupCd;AA4CD,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AAC9D,QAAA,MAAM,eAAe;;;;;;;;;;;;;cA1rCN,OAAO;gBANL,IAAI,EAAE;cAER,OAAO;UADX,KAAK;WAQJ,MAAM;aANJ,OAAO;aACP,OAAO;UAUV,OAAO;WADN,OAAO;iBAHD,MAAM;YALX,MAAM;cAEJ,MAAM,GAAG,IAAI;iBACV,MAAM,GAAG,IAAI;iBAGb,MAAM;gBACP,MAAM;kBAGJ,OAAO;iBACR,sBAAsB;eACxB,MAAM;qBACA,MAAM;gBACX,OAAO;sBACD,MAAM,GAAG,IAAI;gBACnB,MAAM;iBACL,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM;yBACnB,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM;kBACtC,OAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;;;;OAirC3D,CAAC;wBACkB,uBAAuB,CAAC,OAAO,eAAe,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAC;AAAnG,wBAAoG;AAapG,KAAK,uBAAuB,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG;IACxC,QAAO;QACN,MAAM,EAAE,CAAC,CAAC;KAEV,CAAA;CACD,CAAC"}
|