@myelophone/nuxt 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/.devcontainer/Dockerfile +7 -0
  2. package/.devcontainer/devcontainer.json +21 -0
  3. package/.editorconfig +43 -0
  4. package/.github/workflows/package.yml +73 -0
  5. package/.nuxtignore +6 -0
  6. package/.releaserc.js +132 -0
  7. package/.yarn/releases/yarn-4.18.0.cjs +1000 -0
  8. package/.yarnrc.yml +8 -0
  9. package/CHANGELOG.md +141 -0
  10. package/LICENSE +77 -0
  11. package/README.md +3004 -0
  12. package/app/app.vue +455 -0
  13. package/app/assets/css/animation.css +81 -0
  14. package/app/assets/css/backgrounds.css +57 -0
  15. package/app/assets/css/base.css +182 -0
  16. package/app/assets/css/index.css +9 -0
  17. package/app/assets/css/links.css +12 -0
  18. package/app/assets/css/multimedia.css +102 -0
  19. package/app/assets/css/preloader.css +68 -0
  20. package/app/assets/css/print.css +40 -0
  21. package/app/assets/css/tailwind.css +20 -0
  22. package/app/assets/css/theme.css +29 -0
  23. package/app/components/consent/GoogleMap.vue +59 -0
  24. package/app/components/consent/Youtube.vue +27 -0
  25. package/app/components/cookie/Banner.vue +70 -0
  26. package/app/components/cookie/ConsentWrapper.vue +71 -0
  27. package/app/components/cookie/PrivacyPolicy.vue +179 -0
  28. package/app/components/cookie/SettingsModal.vue +387 -0
  29. package/app/components/grid/Col.vue +262 -0
  30. package/app/components/grid/Container.vue +236 -0
  31. package/app/components/grid/Row.vue +248 -0
  32. package/app/components/grid/Stack.vue +233 -0
  33. package/app/components/myelophone/Copyright.vue +5 -0
  34. package/app/components/myelophone/Welcome.vue +107 -0
  35. package/app/components/page/FullscreenPreloader.vue +177 -0
  36. package/app/components/page/Preloader.vue +18 -0
  37. package/app/components/page/ReadyLocalized.vue +19 -0
  38. package/app/components/ready/.gitkeep +0 -0
  39. package/app/components/safe/Email.vue +52 -0
  40. package/app/components/safe/ImgWithLoader.vue +180 -0
  41. package/app/components/safe/NuxtImg.vue +25 -0
  42. package/app/components/safe/NuxtPicture.vue +29 -0
  43. package/app/components/section/.gitkeep +0 -0
  44. package/app/components/seo/BrandInfo.vue +76 -0
  45. package/app/components/seo/ContentNoIndex.vue +35 -0
  46. package/app/components/seo/NoIndex.vue +22 -0
  47. package/app/components/seo/OrgInfo.vue +130 -0
  48. package/app/components/seo/ProfileInfo.vue +137 -0
  49. package/app/components/ui/Accordion.vue +163 -0
  50. package/app/components/ui/Alert.vue +179 -0
  51. package/app/components/ui/Avatar.vue +88 -0
  52. package/app/components/ui/Badge.vue +150 -0
  53. package/app/components/ui/Banner.vue +133 -0
  54. package/app/components/ui/Button.vue +175 -0
  55. package/app/components/ui/Card.vue +66 -0
  56. package/app/components/ui/Checkbox.vue +134 -0
  57. package/app/components/ui/Chip.vue +136 -0
  58. package/app/components/ui/CommandPalette.vue +434 -0
  59. package/app/components/ui/Console.vue +583 -0
  60. package/app/components/ui/CursorCreative.vue +148 -0
  61. package/app/components/ui/FeatureAccordion.vue +165 -0
  62. package/app/components/ui/FileUpload.vue +705 -0
  63. package/app/components/ui/GeoDependent.vue +72 -0
  64. package/app/components/ui/Heading.vue +51 -0
  65. package/app/components/ui/Icon.vue +42 -0
  66. package/app/components/ui/Icon8.vue +39 -0
  67. package/app/components/ui/Input.vue +88 -0
  68. package/app/components/ui/LangLink.vue +34 -0
  69. package/app/components/ui/LangVisible.vue +33 -0
  70. package/app/components/ui/LanguageSelect.vue +202 -0
  71. package/app/components/ui/LightBox.vue +155 -0
  72. package/app/components/ui/LocalTime.vue +170 -0
  73. package/app/components/ui/Modal.vue +161 -0
  74. package/app/components/ui/Popup.vue +200 -0
  75. package/app/components/ui/PostContent.server.vue +23 -0
  76. package/app/components/ui/ProtectedEmail.vue +52 -0
  77. package/app/components/ui/RelativeTime.vue +141 -0
  78. package/app/components/ui/ScheduledContent.vue +150 -0
  79. package/app/components/ui/ScrollToTop.client.vue +48 -0
  80. package/app/components/ui/SegmentedControl.vue +148 -0
  81. package/app/components/ui/SiteSearch.vue +1191 -0
  82. package/app/components/ui/Skeleton.vue +91 -0
  83. package/app/components/ui/SmartContrast.vue +74 -0
  84. package/app/components/ui/SnapContainer.vue +222 -0
  85. package/app/components/ui/SnapSection.vue +294 -0
  86. package/app/components/ui/SonnerStyle.vue +7 -0
  87. package/app/components/ui/SplitSection.vue +99 -0
  88. package/app/components/ui/Steps.vue +858 -0
  89. package/app/components/ui/StickyWrapper.vue +46 -0
  90. package/app/components/ui/Table.vue +251 -0
  91. package/app/components/ui/Tabs.vue +451 -0
  92. package/app/components/ui/TextColumn.vue +36 -0
  93. package/app/components/ui/Textarea.vue +251 -0
  94. package/app/components/ui/Toggler.vue +40 -0
  95. package/app/components/ui/Tooltip.vue +524 -0
  96. package/app/components/ui/TruncateText.vue +105 -0
  97. package/app/components/ui/ViewportSpacer.vue +72 -0
  98. package/app/components/view/AnimatedCounter.vue +121 -0
  99. package/app/components/view/AnnouncementBar.vue +104 -0
  100. package/app/components/view/AvatarGroup.vue +104 -0
  101. package/app/components/view/Breadcrumbs.vue +97 -0
  102. package/app/components/view/ContentWithToc.vue +152 -0
  103. package/app/components/view/Copyright.vue +68 -0
  104. package/app/components/view/CountdownTimer.vue +184 -0
  105. package/app/components/view/CreativeCTA.vue +143 -0
  106. package/app/components/view/CurrencySelect.vue +254 -0
  107. package/app/components/view/FeatureBoxGrid.vue +142 -0
  108. package/app/components/view/HorizontalMenu.vue +97 -0
  109. package/app/components/view/ImageCompare.vue +141 -0
  110. package/app/components/view/ImgSlider.vue +333 -0
  111. package/app/components/view/InfiniteMarquee.vue +147 -0
  112. package/app/components/view/InfoBar.vue +86 -0
  113. package/app/components/view/LogoGrid.vue +106 -0
  114. package/app/components/view/LogoSlider.vue +83 -0
  115. package/app/components/view/NotFound.vue +81 -0
  116. package/app/components/view/PartialBackground.vue +313 -0
  117. package/app/components/view/QuoteBig.vue +111 -0
  118. package/app/components/view/ResponsiveMenu.vue +219 -0
  119. package/app/components/view/ReviewsSlider.vue +230 -0
  120. package/app/components/view/SocialBar.vue +78 -0
  121. package/app/components/view/StickyHeadingContent.vue +158 -0
  122. package/app/components/view/TallyForm.vue +295 -0
  123. package/app/components/view/WarningOutdated.vue +113 -0
  124. package/app/composables/useApi.ts +88 -0
  125. package/app/composables/useAppSeo.ts +78 -0
  126. package/app/composables/useBreadcrumbs.ts +106 -0
  127. package/app/composables/useChangeUrl.ts +73 -0
  128. package/app/composables/useCommand.ts +28 -0
  129. package/app/composables/useCookieControl.ts +111 -0
  130. package/app/composables/useCookieModal.ts +9 -0
  131. package/app/composables/useDevice.ts +109 -0
  132. package/app/composables/useGeoFetch.ts +139 -0
  133. package/app/composables/useLoadScript.ts +36 -0
  134. package/app/composables/useModalState.ts +37 -0
  135. package/app/composables/useMultiLang.ts +278 -0
  136. package/app/composables/useNetwork.ts +49 -0
  137. package/app/composables/useNuxtVersion.ts +5 -0
  138. package/app/composables/usePiniaStore.ts +5 -0
  139. package/app/composables/usePopupControl.ts +57 -0
  140. package/app/composables/useQuery.ts +307 -0
  141. package/app/composables/useReadyPage.ts +182 -0
  142. package/app/composables/useSafeList.ts +90 -0
  143. package/app/composables/useScriptLoader.ts +221 -0
  144. package/app/composables/useSiteSearch.ts +32 -0
  145. package/app/composables/useSnapScroll.ts +24 -0
  146. package/app/composables/useStatic.ts +5 -0
  147. package/app/composables/useStorage.ts +252 -0
  148. package/app/composables/useStoreBroadcast.ts +146 -0
  149. package/app/composables/useThemeSync.ts +81 -0
  150. package/app/constants/layout.ts +109 -0
  151. package/app/constants/predefinedCookieScripts.ts +1222 -0
  152. package/app/constants/ui-events.ts +1 -0
  153. package/app/error.vue +169 -0
  154. package/app/layouts/default-blog.vue +5 -0
  155. package/app/layouts/default.vue +3 -0
  156. package/app/locales/en/commands.json +5 -0
  157. package/app/locales/en/cookies.json +182 -0
  158. package/app/locales/en/error.json +4 -0
  159. package/app/locales/en/interface.json +33 -0
  160. package/app/locales/en/myelophone.json +8 -0
  161. package/app/locales/en/outdated.json +5 -0
  162. package/app/locales/en/times.json +18 -0
  163. package/app/locales/pl/commands.json +5 -0
  164. package/app/locales/pl/cookies.json +182 -0
  165. package/app/locales/pl/error.json +4 -0
  166. package/app/locales/pl/interface.json +33 -0
  167. package/app/locales/pl/myelophone.json +8 -0
  168. package/app/locales/pl/outdated.json +5 -0
  169. package/app/locales/pl/times.json +18 -0
  170. package/app/locales/ru/commands.json +5 -0
  171. package/app/locales/ru/cookies.json +182 -0
  172. package/app/locales/ru/error.json +4 -0
  173. package/app/locales/ru/interface.json +33 -0
  174. package/app/locales/ru/myelophone.json +8 -0
  175. package/app/locales/ru/outdated.json +5 -0
  176. package/app/locales/ru/times.json +18 -0
  177. package/app/middleware/blog-layout.ts +28 -0
  178. package/app/middleware/i18n.global.ts +17 -0
  179. package/app/middleware/not-found-status.ts +11 -0
  180. package/app/modules/default-blog/index.ts +39 -0
  181. package/app/modules/default-blog/posts.ts +80 -0
  182. package/app/modules/default-sitemap/index.ts +116 -0
  183. package/app/modules/default-sitemap/runtime/sitemap.get.ts +42 -0
  184. package/app/modules/fullscreen-preloader/module.ts +43 -0
  185. package/app/modules/htaccess/.htaccess +38 -0
  186. package/app/modules/htaccess/index.ts +23 -0
  187. package/app/modules/i18n-shaker.mjs +221 -0
  188. package/app/modules/multi18n/module.ts +71 -0
  189. package/app/modules/myelophone-config/module.ts +241 -0
  190. package/app/pages/[...all].vue +9 -0
  191. package/app/pages/index.vue +3 -0
  192. package/app/pages/post/[...slug].vue +30 -0
  193. package/app/plugins/bfcache-refresh.client.ts +9 -0
  194. package/app/plugins/canonical.ts +30 -0
  195. package/app/plugins/csp-nonce.server.ts +41 -0
  196. package/app/plugins/multi18n.client.ts +21 -0
  197. package/app/plugins/multi18n.server.ts +15 -0
  198. package/app/plugins/nuxt-version.client.ts +4 -0
  199. package/app/plugins/restore.client.ts +21 -0
  200. package/app/plugins/reveal.ts +89 -0
  201. package/app/plugins/url-helper.client.ts +66 -0
  202. package/app/server/api/exchange-rates.get.ts +216 -0
  203. package/app/server/api/healthz.ts +1 -0
  204. package/app/server/api/logout.post.ts +6 -0
  205. package/app/server/middleware/normalize-url.ts +14 -0
  206. package/app/server/middleware/security-headers.ts +147 -0
  207. package/app/server/middleware/url-helper.ts +43 -0
  208. package/app/server/plugins/clean-html-spaces.ts +15 -0
  209. package/app/server/plugins/clean-styles.ts +94 -0
  210. package/app/server/plugins/compression.ts +122 -0
  211. package/app/server/plugins/css-preload.ts +26 -0
  212. package/app/server/plugins/early-hints.ts +31 -0
  213. package/app/server/plugins/force-noindex.ts +43 -0
  214. package/app/server/plugins/html-one-line.ts +17 -0
  215. package/app/server/plugins/inline-style.ts +7 -0
  216. package/app/server/routes/healthz.get.ts +1 -0
  217. package/app/server/tsconfig.json +3 -0
  218. package/app/stores/cart.ts +945 -0
  219. package/app/stores/commands.ts +43 -0
  220. package/app/stores/device.ts +156 -0
  221. package/app/stores/querySync.ts +5 -0
  222. package/app/stores/settings.ts +145 -0
  223. package/app/stores/user.ts +607 -0
  224. package/app/types/cookie.ts +89 -0
  225. package/app/types/myelophone.d.ts +87 -0
  226. package/app/types/site-search.ts +110 -0
  227. package/app/utils/asyncRefresh.ts +4 -0
  228. package/app/utils/blog.server.ts +142 -0
  229. package/app/utils/jsonLd.ts +56 -0
  230. package/app/utils/myelophoneConfig.ts +4 -0
  231. package/app/utils/sortBy.ts +18 -0
  232. package/app/utils/transliterate.ts +75 -0
  233. package/app/utils/ytVideo.ts +37 -0
  234. package/app/workers/site-search.worker.ts +661 -0
  235. package/biome.json +94 -0
  236. package/commitlint.config.js +3 -0
  237. package/nuxt.config.ts +539 -0
  238. package/package.json +101 -0
  239. package/playground/content/.gitkeep +0 -0
  240. package/playground/locales/.gitkeep +0 -0
  241. package/playground/myelophone.dev.ts +45 -0
  242. package/playground/nuxt.config.ts +105 -0
  243. package/playground/package.json +5 -0
  244. package/playground/pages/.gitkeep +0 -0
  245. package/playground/tsconfig.json +3 -0
  246. package/playwright.config.ts +23 -0
  247. package/public/browsers/chrome.svg +77 -0
  248. package/public/browsers/edge.svg +98 -0
  249. package/public/browsers/firefox.svg +300 -0
  250. package/public/favicon.ico +0 -0
  251. package/public/myelophone_eng.png +0 -0
  252. package/public/myelophone_eng_white.png +0 -0
  253. package/spa-loading-template.html +72 -0
  254. package/tests/home.spec.ts +9 -0
  255. package/tests/utils.ts +23 -0
  256. package/tsconfig.json +16 -0
  257. package/vite/plugins/clean-css.ts +190 -0
  258. package/vite/plugins/postcss-add-viewportunits.ts +33 -0
  259. package/vite/plugins/postcss-viewport-fallback.ts +49 -0
@@ -0,0 +1,705 @@
1
+ <script setup lang="ts">
2
+ type FileUploadValue = File | File[] | null;
3
+ type FileUploadLayout = "grid" | "list";
4
+ type FileUploadRejectionReason = "type" | "size" | "count" | "duplicate";
5
+
6
+ interface FileUploadRejection {
7
+ file: File;
8
+ reasons: FileUploadRejectionReason[];
9
+ }
10
+
11
+ interface Props {
12
+ modelValue?: FileUploadValue;
13
+ id?: string;
14
+ name?: string;
15
+ accept?: string;
16
+ multiple?: boolean;
17
+ required?: boolean;
18
+ disabled?: boolean;
19
+ capture?: boolean | "user" | "environment";
20
+ dropzone?: boolean;
21
+ interactive?: boolean;
22
+ preview?: boolean;
23
+ append?: boolean;
24
+ maxSize?: number;
25
+ maxFiles?: number;
26
+ label?: string;
27
+ description?: string;
28
+ selectText?: string;
29
+ emptyText?: string;
30
+ layout?: FileUploadLayout;
31
+ error?: boolean | string;
32
+ }
33
+
34
+ const props = withDefaults(defineProps<Props>(), {
35
+ modelValue: null,
36
+ multiple: false,
37
+ required: false,
38
+ disabled: false,
39
+ dropzone: true,
40
+ interactive: true,
41
+ preview: true,
42
+ append: true,
43
+ label: "Upload files",
44
+ description: "",
45
+ selectText: "Select files",
46
+ emptyText: "or drag and drop them here",
47
+ layout: "list",
48
+ error: false,
49
+ });
50
+
51
+ const emit = defineEmits<{
52
+ "update:modelValue": [value: FileUploadValue];
53
+ change: [value: FileUploadValue];
54
+ reject: [rejections: FileUploadRejection[]];
55
+ remove: [file: File, index: number];
56
+ clear: [];
57
+ }>();
58
+
59
+ const generatedId = useId();
60
+ const inputId = computed(() => props.id || `file-upload-${generatedId}`);
61
+ const descriptionId = computed(() => `${inputId.value}-description`);
62
+ const errorId = computed(() => `${inputId.value}-error`);
63
+ const input = useTemplateRef<HTMLInputElement>("input");
64
+ const dragDepth = ref(0);
65
+ const isDragging = computed(() => dragDepth.value > 0);
66
+ const previewUrls = shallowRef(new Map<File, string>());
67
+
68
+ const files = computed<File[]>(() => {
69
+ if (Array.isArray(props.modelValue)) return props.modelValue;
70
+ return typeof File !== "undefined" && props.modelValue instanceof File
71
+ ? [props.modelValue]
72
+ : [];
73
+ });
74
+
75
+ const errorMessage = computed(() =>
76
+ typeof props.error === "string" ? props.error : "",
77
+ );
78
+ const hasError = computed(() => Boolean(props.error));
79
+ const describedBy = computed(
80
+ () =>
81
+ [
82
+ props.description ? descriptionId.value : "",
83
+ errorMessage.value ? errorId.value : "",
84
+ ]
85
+ .filter(Boolean)
86
+ .join(" ") || undefined,
87
+ );
88
+
89
+ const acceptsFile = (file: File) => {
90
+ if (!props.accept) return true;
91
+
92
+ return props.accept
93
+ .split(",")
94
+ .map((value) => value.trim().toLowerCase())
95
+ .filter(Boolean)
96
+ .some((rule) => {
97
+ const type = file.type.toLowerCase();
98
+ const name = file.name.toLowerCase();
99
+
100
+ if (rule.startsWith(".")) return name.endsWith(rule);
101
+ if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
102
+ return type === rule;
103
+ });
104
+ };
105
+
106
+ const isDuplicate = (file: File, current: File[]) =>
107
+ current.some(
108
+ (item) =>
109
+ item.name === file.name &&
110
+ item.size === file.size &&
111
+ item.type === file.type &&
112
+ item.lastModified === file.lastModified,
113
+ );
114
+
115
+ const normalizeValue = (nextFiles: File[]): FileUploadValue => {
116
+ if (props.multiple) return nextFiles;
117
+ return nextFiles[0] || null;
118
+ };
119
+
120
+ const commit = (nextFiles: File[]) => {
121
+ const value = normalizeValue(nextFiles);
122
+ emit("update:modelValue", value);
123
+ emit("change", value);
124
+ };
125
+
126
+ const addFiles = (incoming: File[]) => {
127
+ if (props.disabled || !incoming.length) return;
128
+
129
+ const accepted = props.multiple && props.append ? [...files.value] : [];
130
+ const rejections: FileUploadRejection[] = [];
131
+
132
+ for (const file of incoming) {
133
+ const reasons: FileUploadRejectionReason[] = [];
134
+
135
+ if (!acceptsFile(file)) reasons.push("type");
136
+ if (props.maxSize !== undefined && file.size > props.maxSize)
137
+ reasons.push("size");
138
+ if (!props.multiple && accepted.length >= 1) reasons.push("count");
139
+ if (
140
+ props.multiple &&
141
+ props.maxFiles !== undefined &&
142
+ accepted.length >= props.maxFiles
143
+ ) {
144
+ reasons.push("count");
145
+ }
146
+ if (isDuplicate(file, accepted)) reasons.push("duplicate");
147
+
148
+ if (reasons.length) {
149
+ rejections.push({ file, reasons });
150
+ } else {
151
+ accepted.push(file);
152
+ }
153
+ }
154
+
155
+ if (rejections.length) emit("reject", rejections);
156
+ if (accepted.length || !rejections.length) commit(accepted);
157
+ nextTick(syncNativeInput);
158
+ };
159
+
160
+ const open = () => {
161
+ if (!props.disabled) input.value?.click();
162
+ };
163
+
164
+ const removeFile = (index?: number) => {
165
+ if (props.disabled) return;
166
+
167
+ if (index === undefined) {
168
+ if (files.value.length) emit("clear");
169
+ commit([]);
170
+ return;
171
+ }
172
+
173
+ const file = files.value[index];
174
+ if (!file) return;
175
+
176
+ const nextFiles = files.value.filter((_, fileIndex) => fileIndex !== index);
177
+ emit("remove", file, index);
178
+ commit(nextFiles);
179
+ };
180
+
181
+ const clear = () => removeFile();
182
+
183
+ const onInputChange = (event: Event) => {
184
+ const target = event.target as HTMLInputElement;
185
+ addFiles(Array.from(target.files || []));
186
+ };
187
+
188
+ const onDragEnter = (event: DragEvent) => {
189
+ if (
190
+ !props.dropzone ||
191
+ props.disabled ||
192
+ !event.dataTransfer?.types.includes("Files")
193
+ )
194
+ return;
195
+ dragDepth.value += 1;
196
+ };
197
+
198
+ const onDragLeave = () => {
199
+ if (!props.dropzone) return;
200
+ dragDepth.value = Math.max(0, dragDepth.value - 1);
201
+ };
202
+
203
+ const onDrop = (event: DragEvent) => {
204
+ dragDepth.value = 0;
205
+ if (!props.dropzone || props.disabled) return;
206
+ addFiles(Array.from(event.dataTransfer?.files || []));
207
+ };
208
+
209
+ const onRootClick = (event: MouseEvent) => {
210
+ if (!props.interactive || props.disabled) return;
211
+ if ((event.target as HTMLElement).closest("button, a")) return;
212
+ open();
213
+ };
214
+
215
+ const onRootKeydown = (event: KeyboardEvent) => {
216
+ if (
217
+ !props.interactive ||
218
+ props.disabled ||
219
+ !["Enter", " "].includes(event.key)
220
+ )
221
+ return;
222
+ event.preventDefault();
223
+ open();
224
+ };
225
+
226
+ const formatFileSize = (bytes: number) => {
227
+ if (bytes < 1024) return `${bytes} B`;
228
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
229
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
230
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
231
+ };
232
+
233
+ const syncNativeInput = () => {
234
+ if (!input.value || !import.meta.client) return;
235
+
236
+ try {
237
+ const transfer = new DataTransfer();
238
+ for (const file of files.value) transfer.items.add(file);
239
+ input.value.files = transfer.files;
240
+ } catch {
241
+ if (!files.value.length) input.value.value = "";
242
+ }
243
+ };
244
+
245
+ watch(
246
+ [files, () => props.preview],
247
+ ([currentFiles]) => {
248
+ if (import.meta.client) {
249
+ const nextUrls = new Map<File, string>();
250
+
251
+ for (const file of currentFiles) {
252
+ if (!props.preview || !file.type.startsWith("image/")) continue;
253
+ nextUrls.set(
254
+ file,
255
+ previewUrls.value.get(file) || URL.createObjectURL(file),
256
+ );
257
+ }
258
+
259
+ for (const [file, url] of previewUrls.value) {
260
+ if (!nextUrls.has(file)) URL.revokeObjectURL(url);
261
+ }
262
+
263
+ previewUrls.value = nextUrls;
264
+ }
265
+
266
+ nextTick(syncNativeInput);
267
+ },
268
+ { immediate: true },
269
+ );
270
+
271
+ const onFormReset = () => {
272
+ emit("update:modelValue", props.multiple ? [] : null);
273
+ emit("change", props.multiple ? [] : null);
274
+ };
275
+
276
+ let form: HTMLFormElement | null = null;
277
+
278
+ onMounted(() => {
279
+ form = input.value?.form || null;
280
+ form?.addEventListener("reset", onFormReset);
281
+ syncNativeInput();
282
+ });
283
+
284
+ onBeforeUnmount(() => {
285
+ form?.removeEventListener("reset", onFormReset);
286
+ for (const url of previewUrls.value.values()) URL.revokeObjectURL(url);
287
+ });
288
+
289
+ defineExpose({ open, removeFile, clear, input });
290
+ </script>
291
+
292
+ <template>
293
+ <div
294
+ class="ui-file-upload"
295
+ :class="[
296
+ `ui-file-upload--${layout}`,
297
+ { 'ui-file-upload--disabled': disabled },
298
+ ]"
299
+ >
300
+ <input
301
+ :id="inputId"
302
+ ref="input"
303
+ class="ui-file-upload__input"
304
+ type="file"
305
+ :name="name"
306
+ :accept="accept"
307
+ :multiple="multiple"
308
+ :required="required"
309
+ :disabled="disabled"
310
+ :capture="capture"
311
+ :aria-describedby="describedBy"
312
+ :aria-invalid="hasError || undefined"
313
+ @change="onInputChange"
314
+ />
315
+
316
+ <div
317
+ class="ui-file-upload__dropzone"
318
+ :class="{
319
+ 'ui-file-upload__dropzone--dragging': isDragging,
320
+ 'ui-file-upload__dropzone--error': hasError,
321
+ 'ui-file-upload__dropzone--interactive':
322
+ interactive && !disabled,
323
+ 'ui-file-upload__dropzone--compact': !dropzone,
324
+ }"
325
+ :role="interactive ? 'button' : undefined"
326
+ :tabindex="interactive && !disabled ? 0 : undefined"
327
+ :aria-controls="inputId"
328
+ :aria-disabled="disabled || undefined"
329
+ @dragenter.prevent="onDragEnter"
330
+ @dragover.prevent
331
+ @dragleave.prevent="onDragLeave"
332
+ @drop.prevent="onDrop"
333
+ @click="onRootClick"
334
+ @keydown="onRootKeydown"
335
+ >
336
+ <slot
337
+ :files="files"
338
+ :open="open"
339
+ :remove-file="removeFile"
340
+ :clear="clear"
341
+ :dragging="isDragging"
342
+ >
343
+ <slot name="leading">
344
+ <svg
345
+ class="ui-file-upload__icon"
346
+ viewBox="0 0 24 24"
347
+ fill="none"
348
+ aria-hidden="true"
349
+ >
350
+ <path
351
+ d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"
352
+ stroke="currentColor"
353
+ stroke-width="1.7"
354
+ stroke-linecap="round"
355
+ stroke-linejoin="round"
356
+ />
357
+ </svg>
358
+ </slot>
359
+
360
+ <div class="ui-file-upload__copy">
361
+ <slot name="label">
362
+ <span class="ui-file-upload__label">{{ label }}</span>
363
+ </slot>
364
+ <slot name="description">
365
+ <span
366
+ v-if="description"
367
+ :id="descriptionId"
368
+ class="ui-file-upload__description"
369
+ >{{ description }}</span
370
+ >
371
+ <span
372
+ v-else-if="dropzone"
373
+ class="ui-file-upload__description"
374
+ >{{ emptyText }}</span
375
+ >
376
+ </slot>
377
+ </div>
378
+
379
+ <slot
380
+ name="actions"
381
+ :files="files"
382
+ :open="open"
383
+ :remove-file="removeFile"
384
+ >
385
+ <span
386
+ v-if="interactive"
387
+ class="ui-file-upload__select"
388
+ aria-hidden="true"
389
+ >
390
+ {{ selectText }}
391
+ </span>
392
+ <button
393
+ v-else
394
+ type="button"
395
+ class="ui-file-upload__select"
396
+ :disabled="disabled"
397
+ @click.stop="open"
398
+ >
399
+ {{ selectText }}
400
+ </button>
401
+ </slot>
402
+ </slot>
403
+ </div>
404
+
405
+ <p
406
+ v-if="errorMessage"
407
+ :id="errorId"
408
+ class="ui-file-upload__error"
409
+ role="alert"
410
+ >
411
+ {{ errorMessage }}
412
+ </p>
413
+
414
+ <slot
415
+ name="files-top"
416
+ :files="files"
417
+ :remove-file="removeFile"
418
+ :clear="clear"
419
+ />
420
+ <slot
421
+ v-if="preview && files.length"
422
+ name="files"
423
+ :files="files"
424
+ :remove-file="removeFile"
425
+ :clear="clear"
426
+ >
427
+ <ul
428
+ class="ui-file-upload__files"
429
+ :class="`ui-file-upload__files--${layout}`"
430
+ >
431
+ <li
432
+ v-for="(file, index) in files"
433
+ :key="`${file.name}-${file.size}-${file.lastModified}`"
434
+ class="ui-file-upload__file"
435
+ >
436
+ <slot
437
+ name="file"
438
+ :file="file"
439
+ :index="index"
440
+ :remove-file="removeFile"
441
+ :preview-url="previewUrls.get(file)"
442
+ >
443
+ <img
444
+ v-if="previewUrls.get(file)"
445
+ :src="previewUrls.get(file)"
446
+ alt=""
447
+ class="ui-file-upload__preview"
448
+ />
449
+ <div
450
+ v-else
451
+ class="ui-file-upload__file-icon"
452
+ aria-hidden="true"
453
+ >
454
+ {{
455
+ file.name
456
+ .split(".")
457
+ .pop()
458
+ ?.slice(0, 4)
459
+ .toUpperCase() || "FILE"
460
+ }}
461
+ </div>
462
+ <div class="ui-file-upload__file-copy">
463
+ <span
464
+ class="ui-file-upload__file-name"
465
+ :title="file.name"
466
+ >{{ file.name }}</span
467
+ >
468
+ <span class="ui-file-upload__file-size">{{
469
+ formatFileSize(file.size)
470
+ }}</span>
471
+ </div>
472
+ <button
473
+ type="button"
474
+ class="ui-file-upload__remove"
475
+ :aria-label="`Remove ${file.name}`"
476
+ :disabled="disabled"
477
+ @click="removeFile(index)"
478
+ >
479
+ <span aria-hidden="true">×</span>
480
+ </button>
481
+ </slot>
482
+ </li>
483
+ </ul>
484
+ </slot>
485
+ <slot
486
+ name="files-bottom"
487
+ :files="files"
488
+ :remove-file="removeFile"
489
+ :clear="clear"
490
+ />
491
+ </div>
492
+ </template>
493
+
494
+ <style scoped>
495
+ .ui-file-upload {
496
+ width: 100%;
497
+ color: var(--ui-text);
498
+ }
499
+
500
+ .ui-file-upload__input {
501
+ position: absolute;
502
+ width: 1px;
503
+ height: 1px;
504
+ padding: 0;
505
+ margin: -1px;
506
+ overflow: hidden;
507
+ clip: rect(0, 0, 0, 0);
508
+ white-space: nowrap;
509
+ border: 0;
510
+ }
511
+
512
+ .ui-file-upload__dropzone {
513
+ display: flex;
514
+ min-height: 11rem;
515
+ flex-direction: column;
516
+ align-items: center;
517
+ justify-content: center;
518
+ gap: 0.75rem;
519
+ padding: 1.5rem;
520
+ text-align: center;
521
+ background: color-mix(in srgb, var(--ui-text) 2%, var(--ui-bg));
522
+ border: 1px dashed color-mix(in srgb, var(--ui-text) 32%, transparent);
523
+ border-radius: 0.5rem;
524
+ transition:
525
+ border-color 140ms ease,
526
+ background-color 140ms ease,
527
+ box-shadow 140ms ease;
528
+ }
529
+
530
+ .ui-file-upload__dropzone--interactive {
531
+ cursor: pointer;
532
+ }
533
+
534
+ .ui-file-upload__dropzone--interactive:hover,
535
+ .ui-file-upload__dropzone--dragging {
536
+ background: color-mix(in srgb, var(--ui-text) 6%, var(--ui-bg));
537
+ border-color: color-mix(in srgb, var(--ui-text) 65%, transparent);
538
+ }
539
+
540
+ .ui-file-upload__dropzone:focus-visible {
541
+ outline: 2px solid color-mix(in srgb, var(--ui-text) 70%, transparent);
542
+ outline-offset: 2px;
543
+ }
544
+
545
+ .ui-file-upload__dropzone--error {
546
+ border-color: var(--ui-error, #ef4444);
547
+ }
548
+
549
+ .ui-file-upload__dropzone--compact {
550
+ min-height: auto;
551
+ flex-direction: row;
552
+ justify-content: flex-start;
553
+ padding: 0.75rem;
554
+ text-align: start;
555
+ border-style: solid;
556
+ }
557
+
558
+ .ui-file-upload__icon {
559
+ width: 2rem;
560
+ height: 2rem;
561
+ opacity: 0.65;
562
+ }
563
+
564
+ .ui-file-upload__copy,
565
+ .ui-file-upload__file-copy {
566
+ display: flex;
567
+ min-width: 0;
568
+ flex-direction: column;
569
+ gap: 0.2rem;
570
+ }
571
+
572
+ .ui-file-upload__label,
573
+ .ui-file-upload__file-name {
574
+ font-size: 0.9rem;
575
+ font-weight: 600;
576
+ }
577
+
578
+ .ui-file-upload__description,
579
+ .ui-file-upload__file-size {
580
+ font-size: 0.78rem;
581
+ opacity: 0.62;
582
+ }
583
+
584
+ .ui-file-upload__select,
585
+ .ui-file-upload__remove {
586
+ display: inline-flex;
587
+ align-items: center;
588
+ justify-content: center;
589
+ color: var(--ui-text);
590
+ background: var(--ui-bg);
591
+ border: 1px solid color-mix(in srgb, var(--ui-text) 24%, transparent);
592
+ border-radius: 0.4rem;
593
+ cursor: pointer;
594
+ }
595
+
596
+ .ui-file-upload__select {
597
+ min-height: 2.25rem;
598
+ padding: 0.45rem 0.8rem;
599
+ font-size: 0.82rem;
600
+ font-weight: 600;
601
+ }
602
+
603
+ .ui-file-upload__select:disabled,
604
+ .ui-file-upload__remove:disabled,
605
+ .ui-file-upload--disabled {
606
+ cursor: not-allowed;
607
+ opacity: 0.55;
608
+ }
609
+
610
+ .ui-file-upload__error {
611
+ margin: 0.45rem 0 0;
612
+ font-size: 0.78rem;
613
+ color: var(--ui-error, #ef4444);
614
+ }
615
+
616
+ .ui-file-upload__files {
617
+ display: grid;
618
+ gap: 0.6rem;
619
+ padding: 0;
620
+ margin: 0.75rem 0 0;
621
+ list-style: none;
622
+ }
623
+
624
+ .ui-file-upload__files--grid {
625
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
626
+ }
627
+
628
+ .ui-file-upload__file {
629
+ display: flex;
630
+ min-width: 0;
631
+ align-items: center;
632
+ gap: 0.7rem;
633
+ padding: 0.6rem;
634
+ background: color-mix(in srgb, var(--ui-text) 3%, var(--ui-bg));
635
+ border: 1px solid color-mix(in srgb, var(--ui-text) 14%, transparent);
636
+ border-radius: 0.45rem;
637
+ }
638
+
639
+ .ui-file-upload__files--grid .ui-file-upload__file {
640
+ position: relative;
641
+ align-items: stretch;
642
+ flex-direction: column;
643
+ }
644
+
645
+ .ui-file-upload__preview,
646
+ .ui-file-upload__file-icon {
647
+ width: 2.6rem;
648
+ height: 2.6rem;
649
+ flex: 0 0 auto;
650
+ border-radius: 0.35rem;
651
+ }
652
+
653
+ .ui-file-upload__preview {
654
+ object-fit: cover;
655
+ }
656
+
657
+ .ui-file-upload__file-icon {
658
+ display: grid;
659
+ place-items: center;
660
+ font-size: 0.58rem;
661
+ font-weight: 700;
662
+ background: color-mix(in srgb, var(--ui-text) 10%, var(--ui-bg));
663
+ }
664
+
665
+ .ui-file-upload__files--grid .ui-file-upload__preview,
666
+ .ui-file-upload__files--grid .ui-file-upload__file-icon {
667
+ width: 100%;
668
+ height: 7rem;
669
+ }
670
+
671
+ .ui-file-upload__file-name {
672
+ overflow: hidden;
673
+ text-overflow: ellipsis;
674
+ white-space: nowrap;
675
+ }
676
+
677
+ .ui-file-upload__remove {
678
+ width: 1.8rem;
679
+ height: 1.8rem;
680
+ margin-inline-start: auto;
681
+ font-size: 1.2rem;
682
+ line-height: 1;
683
+ }
684
+
685
+ .ui-file-upload__files--grid .ui-file-upload__remove {
686
+ position: absolute;
687
+ top: 0.85rem;
688
+ right: 0.85rem;
689
+ }
690
+
691
+ @media (prefers-reduced-motion: reduce) {
692
+ .ui-file-upload__dropzone {
693
+ transition: none;
694
+ }
695
+ }
696
+
697
+ @media (forced-colors: active) {
698
+ .ui-file-upload__dropzone,
699
+ .ui-file-upload__file,
700
+ .ui-file-upload__select,
701
+ .ui-file-upload__remove {
702
+ border-color: CanvasText;
703
+ }
704
+ }
705
+ </style>