@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,524 @@
1
+ <script setup lang="ts">
2
+ type TooltipSide = "top" | "right" | "bottom" | "left";
3
+ type TooltipAlign = "start" | "center" | "end";
4
+ type TooltipPlacement =
5
+ "auto" | TooltipSide | `${TooltipSide}-start` | `${TooltipSide}-end`;
6
+
7
+ interface Props {
8
+ modelValue?: boolean;
9
+ as?: "span" | "div";
10
+ trigger?: "hover" | "click";
11
+ triggerTabindex?: number;
12
+ placement?: TooltipPlacement;
13
+ offset?: number;
14
+ viewportPadding?: number;
15
+ openDelay?: number;
16
+ closeDelay?: number;
17
+ disabled?: boolean;
18
+ interactive?: boolean;
19
+ maxWidth?: string;
20
+ role?: "tooltip" | "dialog";
21
+ triggerClass?: string;
22
+ contentClass?: string;
23
+ }
24
+
25
+ const props = withDefaults(defineProps<Props>(), {
26
+ modelValue: undefined,
27
+ as: "span",
28
+ trigger: "hover",
29
+ triggerTabindex: 0,
30
+ placement: "auto",
31
+ offset: 10,
32
+ viewportPadding: 8,
33
+ openDelay: 100,
34
+ closeDelay: 120,
35
+ disabled: false,
36
+ interactive: true,
37
+ maxWidth: "24rem",
38
+ role: "tooltip",
39
+ });
40
+
41
+ const emit = defineEmits<{
42
+ "update:modelValue": [value: boolean];
43
+ open: [];
44
+ close: [];
45
+ }>();
46
+
47
+ const uid = useId();
48
+ const tooltipId = `tooltip-${uid.replace(/[^a-zA-Z0-9_-]/g, "")}`;
49
+ const triggerElement = ref<HTMLElement>();
50
+ const tooltipElement = ref<HTMLElement>();
51
+ const internalOpen = ref(false);
52
+ const isMounted = ref(false);
53
+ const isPositioned = ref(false);
54
+ const side = ref<TooltipSide>("top");
55
+ const coordinates = reactive({ x: 0, y: 0 });
56
+ const arrowCoordinates = reactive({ x: 0, y: 0 });
57
+ let openTimer: ReturnType<typeof setTimeout> | undefined;
58
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
59
+ let animationFrame: number | undefined;
60
+ let resizeObserver: ResizeObserver | undefined;
61
+
62
+ const isControlled = computed(() => props.modelValue !== undefined);
63
+ const isOpen = computed(() =>
64
+ isControlled.value ? Boolean(props.modelValue) : internalOpen.value,
65
+ );
66
+ const resolvedTriggerTabindex = computed(() =>
67
+ props.disabled ? -1 : props.triggerTabindex,
68
+ );
69
+
70
+ const tooltipStyle = computed(() => ({
71
+ left: `${coordinates.x}px`,
72
+ top: `${coordinates.y}px`,
73
+ maxWidth: `min(${props.maxWidth}, calc(100vw - ${props.viewportPadding * 2}px))`,
74
+ maxHeight: `calc(100dvh - ${props.viewportPadding * 2}px)`,
75
+ visibility: isPositioned.value ? ("visible" as const) : ("hidden" as const),
76
+ }));
77
+
78
+ const arrowStyle = computed(() => {
79
+ if (side.value === "top" || side.value === "bottom") {
80
+ return { left: `${arrowCoordinates.x}px` };
81
+ }
82
+
83
+ return { top: `${arrowCoordinates.y}px` };
84
+ });
85
+
86
+ const clearTimers = () => {
87
+ if (openTimer) clearTimeout(openTimer);
88
+ if (closeTimer) clearTimeout(closeTimer);
89
+ openTimer = undefined;
90
+ closeTimer = undefined;
91
+ };
92
+
93
+ const setOpen = (value: boolean) => {
94
+ if (value && props.disabled) return;
95
+ if (isOpen.value === value) return;
96
+
97
+ if (!isControlled.value) internalOpen.value = value;
98
+ emit("update:modelValue", value);
99
+ if (value) emit("open");
100
+ else emit("close");
101
+ };
102
+
103
+ const open = (delay = props.openDelay) => {
104
+ if (props.disabled) return;
105
+ if (closeTimer) clearTimeout(closeTimer);
106
+ if (isOpen.value || openTimer) return;
107
+
108
+ openTimer = setTimeout(() => {
109
+ openTimer = undefined;
110
+ setOpen(true);
111
+ }, delay);
112
+ };
113
+
114
+ const close = (delay = props.closeDelay) => {
115
+ if (openTimer) clearTimeout(openTimer);
116
+ openTimer = undefined;
117
+ if (!isOpen.value || closeTimer) return;
118
+
119
+ closeTimer = setTimeout(() => {
120
+ closeTimer = undefined;
121
+ setOpen(false);
122
+ }, delay);
123
+ };
124
+
125
+ const toggle = () => {
126
+ clearTimers();
127
+ setOpen(!isOpen.value);
128
+ };
129
+
130
+ const parsePlacement = (): { preferred?: TooltipSide; align: TooltipAlign } => {
131
+ if (props.placement === "auto") return { align: "center" };
132
+
133
+ const [preferred, requestedAlign] = props.placement.split("-") as [
134
+ TooltipSide,
135
+ TooltipAlign | undefined,
136
+ ];
137
+ return { preferred, align: requestedAlign ?? "center" };
138
+ };
139
+
140
+ const oppositeSides: Record<TooltipSide, TooltipSide> = {
141
+ top: "bottom",
142
+ right: "left",
143
+ bottom: "top",
144
+ left: "right",
145
+ };
146
+
147
+ const updatePosition = () => {
148
+ if (!isOpen.value || !triggerElement.value || !tooltipElement.value) return;
149
+
150
+ const triggerRect = triggerElement.value.getBoundingClientRect();
151
+ const tooltipRect = tooltipElement.value.getBoundingClientRect();
152
+ const viewportWidth = window.innerWidth;
153
+ const viewportHeight = window.innerHeight;
154
+ const padding = props.viewportPadding;
155
+ const gap = props.offset;
156
+ const available: Record<TooltipSide, number> = {
157
+ top: triggerRect.top - padding - gap,
158
+ right: viewportWidth - triggerRect.right - padding - gap,
159
+ bottom: viewportHeight - triggerRect.bottom - padding - gap,
160
+ left: triggerRect.left - padding - gap,
161
+ };
162
+ const required: Record<TooltipSide, number> = {
163
+ top: tooltipRect.height,
164
+ right: tooltipRect.width,
165
+ bottom: tooltipRect.height,
166
+ left: tooltipRect.width,
167
+ };
168
+ const { preferred, align } = parsePlacement();
169
+ const allSides: TooltipSide[] = ["top", "bottom", "right", "left"];
170
+ const candidates = preferred
171
+ ? [
172
+ preferred,
173
+ oppositeSides[preferred],
174
+ ...allSides.filter(
175
+ (candidate) =>
176
+ candidate !== preferred &&
177
+ candidate !== oppositeSides[preferred],
178
+ ),
179
+ ]
180
+ : allSides;
181
+ const fittingSide = candidates.find(
182
+ (candidate) => available[candidate] >= required[candidate],
183
+ );
184
+ const resolvedSide =
185
+ fittingSide ??
186
+ candidates.reduce((best, candidate) =>
187
+ available[candidate] > available[best] ? candidate : best,
188
+ );
189
+
190
+ let x = 0;
191
+ let y = 0;
192
+ if (resolvedSide === "top" || resolvedSide === "bottom") {
193
+ x =
194
+ align === "start"
195
+ ? triggerRect.left
196
+ : align === "end"
197
+ ? triggerRect.right - tooltipRect.width
198
+ : triggerRect.left +
199
+ (triggerRect.width - tooltipRect.width) / 2;
200
+ y =
201
+ resolvedSide === "top"
202
+ ? triggerRect.top - tooltipRect.height - gap
203
+ : triggerRect.bottom + gap;
204
+ } else {
205
+ x =
206
+ resolvedSide === "left"
207
+ ? triggerRect.left - tooltipRect.width - gap
208
+ : triggerRect.right + gap;
209
+ y =
210
+ align === "start"
211
+ ? triggerRect.top
212
+ : align === "end"
213
+ ? triggerRect.bottom - tooltipRect.height
214
+ : triggerRect.top +
215
+ (triggerRect.height - tooltipRect.height) / 2;
216
+ }
217
+
218
+ const maxX = Math.max(padding, viewportWidth - tooltipRect.width - padding);
219
+ const maxY = Math.max(
220
+ padding,
221
+ viewportHeight - tooltipRect.height - padding,
222
+ );
223
+ coordinates.x = Math.round(Math.min(Math.max(x, padding), maxX));
224
+ coordinates.y = Math.round(Math.min(Math.max(y, padding), maxY));
225
+ side.value = resolvedSide;
226
+
227
+ const arrowPadding = 10;
228
+ arrowCoordinates.x = Math.round(
229
+ Math.min(
230
+ Math.max(
231
+ triggerRect.left + triggerRect.width / 2 - coordinates.x,
232
+ arrowPadding,
233
+ ),
234
+ Math.max(arrowPadding, tooltipRect.width - arrowPadding),
235
+ ),
236
+ );
237
+ arrowCoordinates.y = Math.round(
238
+ Math.min(
239
+ Math.max(
240
+ triggerRect.top + triggerRect.height / 2 - coordinates.y,
241
+ arrowPadding,
242
+ ),
243
+ Math.max(arrowPadding, tooltipRect.height - arrowPadding),
244
+ ),
245
+ );
246
+ isPositioned.value = true;
247
+ };
248
+
249
+ const schedulePositionUpdate = () => {
250
+ if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
251
+ animationFrame = requestAnimationFrame(() => {
252
+ animationFrame = undefined;
253
+ updatePosition();
254
+ });
255
+ };
256
+
257
+ const handleTriggerPointerEnter = (event: PointerEvent) => {
258
+ if (props.trigger === "hover" && event.pointerType === "mouse") open();
259
+ };
260
+
261
+ const handleTriggerPointerLeave = (event: PointerEvent) => {
262
+ if (props.trigger === "hover" && event.pointerType === "mouse") close();
263
+ };
264
+
265
+ const handleTriggerPointerUp = (event: PointerEvent) => {
266
+ if (props.trigger === "hover" && event.pointerType !== "mouse") toggle();
267
+ };
268
+
269
+ const handleTriggerClick = () => {
270
+ if (props.trigger === "click") toggle();
271
+ };
272
+
273
+ const handleFocusIn = () => {
274
+ if (closeTimer) clearTimeout(closeTimer);
275
+ closeTimer = undefined;
276
+ if (props.trigger === "hover") open(0);
277
+ };
278
+
279
+ const handleTooltipFocusIn = () => {
280
+ if (closeTimer) clearTimeout(closeTimer);
281
+ closeTimer = undefined;
282
+ };
283
+
284
+ const handleFocusOut = (event: FocusEvent) => {
285
+ const nextTarget = event.relatedTarget;
286
+ if (
287
+ nextTarget instanceof Node &&
288
+ (triggerElement.value?.contains(nextTarget) ||
289
+ tooltipElement.value?.contains(nextTarget))
290
+ ) {
291
+ return;
292
+ }
293
+ close();
294
+ };
295
+
296
+ const handleDocumentPointerDown = (event: PointerEvent) => {
297
+ const target = event.target;
298
+ if (!(target instanceof Node)) return;
299
+ if (
300
+ triggerElement.value?.contains(target) ||
301
+ tooltipElement.value?.contains(target)
302
+ ) {
303
+ return;
304
+ }
305
+ close(0);
306
+ };
307
+
308
+ const handleKeydown = (event: KeyboardEvent) => {
309
+ if (event.key === "Escape" && isOpen.value) {
310
+ clearTimers();
311
+ setOpen(false);
312
+ triggerElement.value?.focus();
313
+ }
314
+
315
+ if (
316
+ props.trigger === "click" &&
317
+ event.target === triggerElement.value &&
318
+ (event.key === "Enter" || event.key === " ")
319
+ ) {
320
+ event.preventDefault();
321
+ toggle();
322
+ }
323
+ };
324
+
325
+ watch(isOpen, async (value) => {
326
+ if (!value) {
327
+ isPositioned.value = false;
328
+ return;
329
+ }
330
+
331
+ await nextTick();
332
+ updatePosition();
333
+ });
334
+
335
+ watch(
336
+ () => props.disabled,
337
+ (value) => {
338
+ if (value) {
339
+ clearTimers();
340
+ setOpen(false);
341
+ }
342
+ },
343
+ );
344
+
345
+ onMounted(() => {
346
+ isMounted.value = true;
347
+ window.addEventListener("resize", schedulePositionUpdate, {
348
+ passive: true,
349
+ });
350
+ window.addEventListener("scroll", schedulePositionUpdate, {
351
+ passive: true,
352
+ capture: true,
353
+ });
354
+ document.addEventListener("pointerdown", handleDocumentPointerDown);
355
+ document.addEventListener("keydown", handleKeydown);
356
+ resizeObserver = new ResizeObserver(schedulePositionUpdate);
357
+ if (triggerElement.value) resizeObserver.observe(triggerElement.value);
358
+ });
359
+
360
+ onBeforeUnmount(() => {
361
+ clearTimers();
362
+ if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
363
+ window.removeEventListener("resize", schedulePositionUpdate);
364
+ window.removeEventListener("scroll", schedulePositionUpdate, true);
365
+ document.removeEventListener("pointerdown", handleDocumentPointerDown);
366
+ document.removeEventListener("keydown", handleKeydown);
367
+ resizeObserver?.disconnect();
368
+ });
369
+
370
+ watch(tooltipElement, (element) => {
371
+ if (element) resizeObserver?.observe(element);
372
+ });
373
+
374
+ defineExpose({
375
+ open: () => open(0),
376
+ close: () => close(0),
377
+ toggle,
378
+ updatePosition,
379
+ });
380
+ </script>
381
+
382
+ <template>
383
+ <component
384
+ :is="as"
385
+ ref="triggerElement"
386
+ class="ui-tooltip-trigger"
387
+ :class="triggerClass"
388
+ :tabindex="resolvedTriggerTabindex"
389
+ :aria-disabled="disabled || undefined"
390
+ :aria-describedby="isOpen && role === 'tooltip' ? tooltipId : undefined"
391
+ :aria-expanded="role === 'dialog' ? isOpen : undefined"
392
+ :aria-controls="role === 'dialog' ? tooltipId : undefined"
393
+ :aria-haspopup="role === 'dialog' ? 'dialog' : undefined"
394
+ @pointerenter="handleTriggerPointerEnter"
395
+ @pointerleave="handleTriggerPointerLeave"
396
+ @pointerup="handleTriggerPointerUp"
397
+ @click="handleTriggerClick"
398
+ @focusin="handleFocusIn"
399
+ @focusout="handleFocusOut"
400
+ >
401
+ <slot :open="isOpen" />
402
+ </component>
403
+
404
+ <Teleport v-if="isMounted" to="body">
405
+ <Transition name="ui-tooltip">
406
+ <div
407
+ v-if="isOpen"
408
+ :id="tooltipId"
409
+ ref="tooltipElement"
410
+ data-nosnippet
411
+ :role="role"
412
+ :data-side="side"
413
+ class="ui-tooltip-content"
414
+ :class="contentClass"
415
+ :style="tooltipStyle"
416
+ @pointerenter="interactive && clearTimers()"
417
+ @pointerleave="interactive && close()"
418
+ @focusin="interactive && handleTooltipFocusIn()"
419
+ @focusout="interactive && handleFocusOut($event)"
420
+ >
421
+ <div class="ui-tooltip-body">
422
+ <slot name="content" :close="() => close(0)" />
423
+ </div>
424
+ <span
425
+ class="ui-tooltip-arrow"
426
+ :style="arrowStyle"
427
+ aria-hidden="true"
428
+ ></span>
429
+ </div>
430
+ </Transition>
431
+ </Teleport>
432
+ </template>
433
+
434
+ <style scoped>
435
+ .ui-tooltip-trigger {
436
+ display: inline-flex;
437
+ max-width: 100%;
438
+ }
439
+
440
+ .ui-tooltip-content {
441
+ position: fixed;
442
+ z-index: 100;
443
+ box-sizing: border-box;
444
+ overflow: visible;
445
+ color: var(--tooltip-color, white);
446
+ font-size: 0.875rem;
447
+ line-height: 1.35;
448
+ text-align: left;
449
+ overflow-wrap: anywhere;
450
+ background: var(--tooltip-background, rgb(23 23 23));
451
+ border: 1px solid var(--tooltip-border, rgb(255 255 255 / 0.12));
452
+ border-radius: var(--tooltip-radius, 0.5rem);
453
+ box-shadow: var(--tooltip-shadow, 0 10px 30px rgb(0 0 0 / 0.22));
454
+ }
455
+
456
+ .ui-tooltip-body {
457
+ position: relative;
458
+ z-index: 1;
459
+ box-sizing: border-box;
460
+ max-height: inherit;
461
+ padding: 0.5rem 0.75rem;
462
+ overflow: auto;
463
+ background: inherit;
464
+ border-radius: inherit;
465
+ overscroll-behavior: contain;
466
+ }
467
+
468
+ .ui-tooltip-content:not([role="dialog"]) {
469
+ pointer-events: v-bind("interactive ? 'auto' : 'none'");
470
+ }
471
+
472
+ .ui-tooltip-arrow {
473
+ position: absolute;
474
+ width: 0.625rem;
475
+ height: 0.625rem;
476
+ background: inherit;
477
+ border: inherit;
478
+ transform: translate(-50%, -50%) rotate(45deg);
479
+ }
480
+
481
+ .ui-tooltip-content[data-side="top"] .ui-tooltip-arrow {
482
+ top: 100%;
483
+ border-top: 0;
484
+ border-left: 0;
485
+ }
486
+
487
+ .ui-tooltip-content[data-side="bottom"] .ui-tooltip-arrow {
488
+ top: 0;
489
+ border-right: 0;
490
+ border-bottom: 0;
491
+ }
492
+
493
+ .ui-tooltip-content[data-side="left"] .ui-tooltip-arrow {
494
+ left: 100%;
495
+ border-bottom: 0;
496
+ border-left: 0;
497
+ }
498
+
499
+ .ui-tooltip-content[data-side="right"] .ui-tooltip-arrow {
500
+ left: 0;
501
+ border-top: 0;
502
+ border-right: 0;
503
+ }
504
+
505
+ .ui-tooltip-enter-active,
506
+ .ui-tooltip-leave-active {
507
+ transition:
508
+ opacity 120ms ease,
509
+ transform 120ms ease;
510
+ }
511
+
512
+ .ui-tooltip-enter-from,
513
+ .ui-tooltip-leave-to {
514
+ opacity: 0;
515
+ transform: scale(0.96);
516
+ }
517
+
518
+ @media (prefers-reduced-motion: reduce) {
519
+ .ui-tooltip-enter-active,
520
+ .ui-tooltip-leave-active {
521
+ transition: none;
522
+ }
523
+ }
524
+ </style>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ mobileLimit?: number;
4
+ desktopLimit?: number;
5
+ }>();
6
+
7
+ const slots = useSlots();
8
+ const isExpanded = ref(false);
9
+ const isMounted = ref(false);
10
+ const windowWidth = ref(0);
11
+
12
+ const rawText = computed(() => {
13
+ const content = slots.default?.() || [];
14
+ return content.map((node) => node.children).join("") || "";
15
+ });
16
+
17
+ const getTruncated = (text: string, limit: number | undefined) => {
18
+ if (!limit || text.length <= limit) return text;
19
+
20
+ const safeLimit = limit - 12;
21
+ const subString = text.substring(0, safeLimit > 0 ? safeLimit : limit);
22
+
23
+ const lastSpace = subString.lastIndexOf(" ");
24
+ return lastSpace > 0 ? subString.substring(0, lastSpace) : subString;
25
+ };
26
+
27
+ const effectiveMobileLimit = computed(
28
+ () => props.mobileLimit || props.desktopLimit,
29
+ );
30
+ const effectiveDesktopLimit = computed(
31
+ () => props.desktopLimit || props.mobileLimit,
32
+ );
33
+
34
+ const mobileText = computed(() =>
35
+ getTruncated(rawText.value, effectiveMobileLimit.value),
36
+ );
37
+ const desktopText = computed(() =>
38
+ getTruncated(rawText.value, effectiveDesktopLimit.value),
39
+ );
40
+
41
+ const canExpand = computed(() => {
42
+ const currentLimit =
43
+ windowWidth.value < 768
44
+ ? effectiveMobileLimit.value
45
+ : effectiveDesktopLimit.value;
46
+
47
+ return currentLimit ? rawText.value.length > currentLimit : false;
48
+ });
49
+
50
+ const updateWidth = () => {
51
+ windowWidth.value = window.innerWidth;
52
+ };
53
+
54
+ onMounted(() => {
55
+ isMounted.value = true;
56
+ updateWidth();
57
+ window.addEventListener("resize", updateWidth);
58
+ });
59
+
60
+ onUnmounted(() => {
61
+ window.removeEventListener("resize", updateWidth);
62
+ });
63
+
64
+ const { t } = useMultiLang(["interface"]);
65
+ </script>
66
+
67
+ <template>
68
+ <div class="inline">
69
+ <template v-if="!isMounted">
70
+ <span v-if="mobileLimit" class="md:hidden">
71
+ {{ mobileText }}...
72
+ </span>
73
+ <span v-if="desktopLimit" class="hidden md:inline">
74
+ {{ desktopText }}...
75
+ </span>
76
+ </template>
77
+
78
+ <template v-else>
79
+ <span>
80
+ {{
81
+ isExpanded
82
+ ? rawText
83
+ : windowWidth < 768
84
+ ? mobileText
85
+ : desktopText
86
+ }}
87
+ <span v-if="!isExpanded && canExpand">...</span>
88
+ </span>
89
+
90
+ <button
91
+ v-if="canExpand"
92
+ @click="isExpanded = !isExpanded"
93
+ class="ml-1 text-blue-600 hover:text-blue-800 underline decoration-dotted"
94
+ >
95
+ {{
96
+ isExpanded ? t("interface.collapse") : t("interface.expand")
97
+ }}
98
+ </button>
99
+ </template>
100
+
101
+ <span class="sr-only" aria-hidden="false">
102
+ {{ rawText }}
103
+ </span>
104
+ </div>
105
+ </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ interface Props {
3
+ size?: number | string;
4
+ mobileSize?: number | string;
5
+ horizontal?: boolean;
6
+ }
7
+
8
+ const props = withDefaults(defineProps<Props>(), {
9
+ size: 5,
10
+ horizontal: false,
11
+ });
12
+
13
+ const spacerStyle = computed(() => {
14
+ const u = props.horizontal ? "vw" : "vh";
15
+ const du = props.horizontal ? "dvw" : "dvh";
16
+
17
+ const desktop =
18
+ typeof props.size === "number"
19
+ ? `${props.size}`
20
+ : props.size.toString().replace(/[a-z]/g, "");
21
+ const mobile =
22
+ props.mobileSize !== undefined
23
+ ? typeof props.mobileSize === "number"
24
+ ? props.mobileSize
25
+ : props.mobileSize.toString().replace(/[a-z]/g, "")
26
+ : desktop;
27
+
28
+ return {
29
+ "--sp-desktop": `${desktop}${u}`,
30
+ "--sp-desktop-d": `${desktop}${du}`,
31
+ "--sp-mobile": `${mobile}${u}`,
32
+ "--sp-mobile-d": `${mobile}${du}`,
33
+ };
34
+ });
35
+ </script>
36
+
37
+ <template>
38
+ <div
39
+ :style="spacerStyle"
40
+ class="viewport-spacer"
41
+ :class="[horizontal ? 'is-horizontal' : 'is-vertical']"
42
+ aria-hidden="true"
43
+ />
44
+ </template>
45
+
46
+ <style scoped>
47
+ .viewport-spacer {
48
+ display: block;
49
+ flex-shrink: 0;
50
+ pointer-events: none;
51
+
52
+ --final-size: var(--sp-mobile);
53
+ --final-size: var(--sp-mobile-d);
54
+ }
55
+
56
+ @media (min-width: 768px) {
57
+ .viewport-spacer {
58
+ --final-size: var(--sp-desktop);
59
+ --final-size: var(--sp-desktop-d);
60
+ }
61
+ }
62
+
63
+ .is-vertical {
64
+ width: 100%;
65
+ height: var(--final-size);
66
+ }
67
+
68
+ .is-horizontal {
69
+ height: 100%;
70
+ width: var(--final-size);
71
+ }
72
+ </style>