@rimelight/ui 0.0.36 → 0.0.37

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 (44) hide show
  1. package/package.json +24 -20
  2. package/src/components/astro/RLAButton.astro +58 -26
  3. package/src/components/astro/RLACarousel.astro +202 -26
  4. package/src/components/astro/RLALink.astro +10 -12
  5. package/src/components/astro/RLALocaleSelector.astro +2 -1
  6. package/src/components/astro/RLAPopover.astro +99 -5
  7. package/src/components/astro/RLAScrollToTop.astro +10 -19
  8. package/src/components/astro/RLASelect.astro +52 -47
  9. package/src/components/astro/RLASidebar.astro +1 -1
  10. package/src/components/astro/RLATableOfContents.astro +76 -27
  11. package/src/components/astro/RLAThemeSelector.astro +1 -0
  12. package/src/components/astro/RLAToast.astro +2 -2
  13. package/src/components/vue/RLVScrollToTop.vue +12 -10
  14. package/src/components/vue/RLVToast.vue +1 -1
  15. package/src/components/vue/RLVToaster.vue +15 -3
  16. package/src/config/index.ts +0 -1
  17. package/src/integrations/index.ts +0 -1
  18. package/src/integrations/ui.ts +12 -11
  19. package/src/themes/link-group.theme.ts +1 -1
  20. package/src/themes/locale-selector.theme.ts +1 -1
  21. package/src/themes/popover.theme.ts +2 -5
  22. package/src/themes/select.theme.ts +1 -1
  23. package/src/themes/toast.theme.ts +9 -3
  24. package/src/types/components/button.ts +2 -1
  25. package/src/types/components/link.ts +0 -10
  26. package/src/types/components/sidebar.ts +1 -1
  27. package/src/types/components/table-of-contents.ts +5 -0
  28. package/src/types/components/toast.ts +1 -1
  29. package/src/utils/index.ts +2 -0
  30. package/src/utils/merge.ts +62 -0
  31. package/src/utils/scroll.ts +41 -0
  32. package/src/utils/toast.ts +37 -0
  33. package/src/utils/useUi.ts +2 -2
  34. package/src/components/HttpObservatory.astro +0 -103
  35. package/src/components/LighthouseScores.astro +0 -204
  36. package/src/composables/index.ts +0 -4
  37. package/src/composables/useHeaderStore.ts +0 -14
  38. package/src/composables/useScrollToTop.ts +0 -62
  39. package/src/composables/useShortcuts.ts +0 -11
  40. package/src/composables/useToast.ts +0 -43
  41. package/src/config/security.ts +0 -227
  42. package/src/integrations/sri.ts +0 -92
  43. package/src/middleware/index.ts +0 -1
  44. package/src/middleware/security.ts +0 -210
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/ui",
3
- "version": "0.0.36",
3
+ "version": "0.0.37",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment UI Library.",
6
6
  "keywords": [
@@ -34,8 +34,6 @@
34
34
  "./components/vue/*": "./src/components/vue/*",
35
35
  "./themes": "./src/themes/index.ts",
36
36
  "./themes/*": "./src/themes/*",
37
- "./composables": "./src/composables/index.ts",
38
- "./composables/*": "./src/composables/*",
39
37
  "./config": "./src/config/index.ts",
40
38
  "./config/*": "./src/config/*",
41
39
  "./utils": "./src/utils/index.ts",
@@ -46,8 +44,6 @@
46
44
  "./docs/*": "./src/docs/*",
47
45
  "./plugins": "./src/plugins/index.ts",
48
46
  "./plugins/*": "./src/plugins/*",
49
- "./middleware": "./src/middleware/index.ts",
50
- "./middleware/*": "./src/middleware/*",
51
47
  "./types": "./src/types/index.ts",
52
48
  "./types/*": "./src/types/*",
53
49
  "./presets": "./src/presets/index.ts",
@@ -62,38 +58,46 @@
62
58
  "docs:meta": "tsx scripts/extract-meta.ts"
63
59
  },
64
60
  "dependencies": {
65
- "@astrojs/vue": "6.0.1",
66
61
  "@iconify-json/lucide": "1.2.113",
67
62
  "@unocss/astro": "^66.7.2",
68
63
  "css-variants": "2.3.5",
69
- "defu": "6.1.7",
70
- "embla-carousel": "8.6.0",
71
- "nanostores": "1.3.0",
72
- "picomatch": "^4.0.4",
73
- "vite-plugin-virtual": "^0.5.0",
74
- "vue": "3.5.38"
64
+ "nanostores": "1.3.0"
75
65
  },
76
66
  "devDependencies": {
77
67
  "@astrojs/check": "0.9.9",
68
+ "@astrojs/solid-js": "7.0.1",
78
69
  "@astrojs/ts-plugin": "1.10.9",
79
- "@e18e/eslint-plugin": "0.5.1",
80
- "@types/node": "25.9.3",
81
- "@types/picomatch": "^4.0.3",
82
- "@unocss/eslint-plugin": "66.7.2",
83
- "astro": "6.4.8",
84
- "eslint-plugin-regexp": "3.1.0",
70
+ "@astrojs/vue": "7.0.1",
71
+ "astro": "7.0.2",
72
+ "solid-js": "1.9.13",
85
73
  "tsx": "4.22.4",
86
74
  "typescript": "6.0.3",
87
75
  "unocss": "66.7.2",
88
- "vite-plus": "0.2.1"
76
+ "vue": "3.5.38"
89
77
  },
90
78
  "peerDependencies": {
79
+ "@astrojs/solid-js": ">=7.0.1",
80
+ "@astrojs/vue": ">=7.0.1",
91
81
  "@rimelight/i18n": "workspace:^0.0.1",
92
- "astro": ">=6.0.0"
82
+ "astro": ">=7.0.1",
83
+ "solid-js": ">=1.9.13",
84
+ "vue": ">=3.0.0"
93
85
  },
94
86
  "peerDependenciesMeta": {
87
+ "@astrojs/solid-js": {
88
+ "optional": true
89
+ },
90
+ "@astrojs/vue": {
91
+ "optional": true
92
+ },
95
93
  "@rimelight/i18n": {
96
94
  "optional": true
95
+ },
96
+ "solid-js": {
97
+ "optional": true
98
+ },
99
+ "vue": {
100
+ "optional": true
97
101
  }
98
102
  },
99
103
  "packageManager": "pnpm@11.8.0"
@@ -5,6 +5,7 @@ import type { ButtonProps } from "../../types"
5
5
  import buttonThemeDefault, { createButtonTheme } from "../../themes/button.theme"
6
6
  import { getUIConfig } from "virtual:rimelight-ui"
7
7
  import RLAAvatar from "./RLAAvatar.astro"
8
+ import RLALink from "./RLALink.astro"
8
9
 
9
10
  const uiConfig = getUIConfig()
10
11
  const buttonTheme = uiConfig.colors ? createButtonTheme(uiConfig.colors) : buttonThemeDefault
@@ -16,6 +17,7 @@ const {
16
17
  color = "primary",
17
18
  size = "md",
18
19
  href,
20
+ to,
19
21
  label,
20
22
  leadingIcon,
21
23
  trailingIcon,
@@ -38,7 +40,7 @@ const {
38
40
  activeVariant?: string
39
41
  }
40
42
 
41
- const isLeading = !!(leadingIcon || avatar || Astro.slots.has("leading")) || loading
43
+ const isLeading = !!(leadingIcon || avatar || Astro.slots.has("leading") || loading)
42
44
  const isTrailing = !!(trailingIcon || Astro.slots.has("trailing")) || (loading && !isLeading)
43
45
 
44
46
  const showLeadingIcon = loading
@@ -66,14 +68,14 @@ const classes = resolveClasses(button, {
66
68
  theme
67
69
  }, useUi("button", uiProp), className);
68
70
 
69
- const Tag = href ? "a" : "button";
71
+ const Tag = (href || to) ? RLALink : "button";
70
72
 
71
73
  const loadingAutoProps = loadingAuto ? { 'data-loading-auto': '' } : {};
72
74
  ---
73
75
  <Tag
74
76
  class={classes.root}
75
77
  data-slot="root"
76
- {...(Tag === "a" ? { href } : {})}
78
+ {...(Tag === RLALink ? { href, to, noExternalIcon: true } : {})}
77
79
  {...loadingAutoProps}
78
80
  {...rest}
79
81
  >
@@ -113,31 +115,61 @@ const loadingAutoProps = loadingAuto ? { 'data-loading-auto': '' } : {};
113
115
  {loadingAuto && (
114
116
  <script>
115
117
  document.querySelectorAll('button[data-loading-auto]').forEach((button) => {
116
- button.addEventListener('click', async (_event) => {
117
- // Find if there are any other click listeners that return a promise
118
- // This is tricky in vanilla JS, but we can assume if it's data-loading-auto,
119
- // the user wants us to track the click.
120
- // If the button is already loading, don't do anything
118
+ button.addEventListener('click', async (event) => {
121
119
  if (button.getAttribute('data-loading') === 'true') return;
122
120
 
123
- // We can't easily intercept other listeners, but if this is an Astro component,
124
- // the onClick might be passed via ...rest as a string or handled via a separate script.
125
-
126
- // A better way: if data-loading-auto is present, we wrap the click.
127
- // But we need to know what to wait for.
128
-
129
- // In Rimelight UI, we handle this via native DOM events.
130
- // In Astro, if the user adds an event listener in their own script, we don't know.
131
-
132
- // If they use standard onClick attribute:
133
- const onClick = button.getAttribute('onclick');
134
- if (onClick) {
135
- button.setAttribute('data-loading', 'true');
136
- button.classList.add('is-loading'); // We might need to ensure the CSS handles this
137
- // Actually, the styles are driven by the classes generated by scv.
138
- // Changing those classes dynamically is hard without knowing the full list.
139
-
140
- // However, if we just want PARITY in the API, we can at least provide the hook.
121
+ // Extract onclick attribute if available
122
+ const onClickAttr = button.getAttribute('onclick');
123
+ if (onClickAttr) {
124
+ // Temporarily disable default onclick execution to run it through our promise tracker
125
+ event.preventDefault();
126
+ event.stopImmediatePropagation();
127
+
128
+ // Construct a function to await the promise
129
+ try {
130
+ button.setAttribute('data-loading', 'true');
131
+ button.setAttribute('disabled', 'true');
132
+
133
+ // To spin the icon dynamically:
134
+ let leadingIconSpan = button.querySelector('[data-slot="leading-icon"]');
135
+ let icon = leadingIconSpan ? leadingIconSpan.querySelector('span') : null;
136
+ let origClass = '';
137
+ let isDynamicallyAdded = false;
138
+
139
+ if (!leadingIconSpan) {
140
+ isDynamicallyAdded = true;
141
+ leadingIconSpan = document.createElement('span');
142
+ leadingIconSpan.className = 'flex items-center justify-center shrink-0';
143
+ leadingIconSpan.setAttribute('data-slot', 'leading-icon');
144
+ icon = document.createElement('span');
145
+ icon.className = 'i-lucide-loader-circle animate-spin';
146
+ leadingIconSpan.appendChild(icon);
147
+ button.insertBefore(leadingIconSpan, button.firstChild);
148
+ } else if (icon) {
149
+ origClass = icon.className;
150
+ icon.className = 'i-lucide-loader-circle animate-spin';
151
+ }
152
+
153
+ // Execute the promise
154
+ const promiseFn = new Function('event', `
155
+ const fn = () => { ${onClickAttr} };
156
+ return fn();
157
+ `);
158
+ await promiseFn.call(button, event);
159
+
160
+ // Restore state
161
+ button.removeAttribute('data-loading');
162
+ button.removeAttribute('disabled');
163
+ if (isDynamicallyAdded && leadingIconSpan) {
164
+ leadingIconSpan.remove();
165
+ } else if (icon) {
166
+ icon.className = origClass;
167
+ }
168
+ } catch (err) {
169
+ console.error(err);
170
+ button.removeAttribute('data-loading');
171
+ button.removeAttribute('disabled');
172
+ }
141
173
  }
142
174
  });
143
175
  });
@@ -28,13 +28,15 @@ const carouselId = `rla-carousel-${Math.random().toString(36).substring(2, 9)}`
28
28
  {slides ? (
29
29
  slides.map((slide) => (
30
30
  <div class={classes.slide} data-slot="slide">
31
- {slide.imageSrc && <img src={slide.imageSrc} alt={slide.title || ""} class="w-full aspect-video object-cover rounded-lg" />}
32
- {(slide.title || slide.description) && (
33
- <div class="absolute bottom-4 left-4 right-4 p-4 rounded-lg bg-black/60 backdrop-blur-sm text-white">
34
- {slide.title && <h4 class="text-sm font-semibold">{slide.title}</h4>}
35
- {slide.description && <p class="text-xs opacity-95 mt-1">{slide.description}</p>}
36
- </div>
37
- )}
31
+ <div class="relative w-full aspect-video">
32
+ {slide.imageSrc && <img src={slide.imageSrc} alt={slide.title || ""} class="w-full h-full object-cover rounded-lg" draggable="false" />}
33
+ {(slide.title || slide.description) && (
34
+ <div class="absolute bottom-4 left-4 right-4 p-4 rounded-lg bg-black/60 backdrop-blur-sm text-white">
35
+ {slide.title && <h4 class="text-sm font-semibold">{slide.title}</h4>}
36
+ {slide.description && <p class="text-xs opacity-95 mt-1">{slide.description}</p>}
37
+ </div>
38
+ )}
39
+ </div>
38
40
  </div>
39
41
  ))
40
42
  ) : (
@@ -53,47 +55,221 @@ const carouselId = `rla-carousel-${Math.random().toString(36).substring(2, 9)}`
53
55
  </rla-carousel>
54
56
 
55
57
  <script>
56
- import EmblaCarousel from "embla-carousel"
57
-
58
58
  class RLACarousel extends HTMLElement {
59
- private emblaCleanup: (() => void) | null = null;
59
+ private cleanup: (() => void) | null = null;
60
60
 
61
61
  connectedCallback() {
62
62
  this.style.display = 'contents';
63
63
  const autoplay = this.getAttribute("data-autoplay") === "true";
64
64
  const viewport = this.querySelector('[data-slot="viewport"]') as HTMLElement;
65
+ const container = this.querySelector('[data-slot="container"]') as HTMLElement;
65
66
  const prevBtn = this.querySelector('[data-slot="prev-button"]') as HTMLButtonElement;
66
67
  const nextBtn = this.querySelector('[data-slot="next-button"]') as HTMLButtonElement;
67
68
 
68
- if (!viewport) return;
69
+ if (!viewport || !container) return;
70
+
71
+ const originalSlides = Array.from(container.children) as HTMLElement[];
72
+ const N = originalSlides.length;
73
+ if (N === 0) return;
74
+
75
+ // Clone first and last slides for seamless looping
76
+ const firstClone = originalSlides[0].cloneNode(true) as HTMLElement;
77
+ const lastClone = originalSlides[N - 1].cloneNode(true) as HTMLElement;
78
+
79
+ container.insertBefore(lastClone, originalSlides[0]);
80
+ container.appendChild(firstClone);
81
+
82
+ // Add draggable="false" to all images inside clones as well
83
+ container.querySelectorAll('img').forEach(img => {
84
+ img.setAttribute('draggable', 'false');
85
+ });
69
86
 
70
- const embla = EmblaCarousel(viewport, { loop: true });
87
+ let currentIndex = 1; // Start at the first actual slide
88
+ let isTransitioning = false;
89
+ let startX = 0;
90
+ let startY = 0;
91
+ let isDragging = false;
92
+ let hasDragged = false;
93
+ let autoplayIntervalId: any = null;
94
+
95
+ const updateTransform = (withTransition: boolean, translatePercent?: number) => {
96
+ if (withTransition) {
97
+ container.style.transition = 'transform 500ms cubic-bezier(0.16, 1, 0.3, 1)';
98
+ } else {
99
+ container.style.transition = 'none';
100
+ }
101
+ const pct = translatePercent !== undefined ? translatePercent : -currentIndex * 100;
102
+ container.style.transform = `translate3d(${pct}%, 0px, 0px)`;
103
+ };
104
+
105
+ // Set initial position
106
+ updateTransform(false);
107
+
108
+ const handleTransitionEnd = () => {
109
+ isTransitioning = false;
110
+ if (currentIndex === 0) {
111
+ currentIndex = N;
112
+ updateTransform(false);
113
+ } else if (currentIndex === N + 1) {
114
+ currentIndex = 1;
115
+ updateTransform(false);
116
+ }
117
+ };
71
118
 
72
- const onPrevClick = () => embla.scrollPrev();
73
- const onNextClick = () => embla.scrollNext();
119
+ const goToIndex = (index: number) => {
120
+ if (isTransitioning) return;
121
+ isTransitioning = true;
122
+ currentIndex = index;
123
+ updateTransform(true);
124
+ };
125
+
126
+ const scrollPrev = () => {
127
+ goToIndex(currentIndex - 1);
128
+ };
129
+
130
+ const scrollNext = () => {
131
+ goToIndex(currentIndex + 1);
132
+ };
133
+
134
+ // Autoplay
135
+ const startAutoplay = () => {
136
+ if (autoplay && !autoplayIntervalId) {
137
+ autoplayIntervalId = setInterval(scrollNext, 5000);
138
+ }
139
+ };
140
+
141
+ const stopAutoplay = () => {
142
+ if (autoplayIntervalId) {
143
+ clearInterval(autoplayIntervalId);
144
+ autoplayIntervalId = null;
145
+ }
146
+ };
147
+
148
+ startAutoplay();
149
+
150
+ // Button event listeners with throttle
151
+ let lastBtnClickTime = 0;
152
+ const handleBtnClick = (action: () => void) => {
153
+ const now = Date.now();
154
+ if (now - lastBtnClickTime < 500) return;
155
+ lastBtnClickTime = now;
156
+ stopAutoplay();
157
+ action();
158
+ startAutoplay();
159
+ };
160
+
161
+ const onPrevClick = (e: MouseEvent) => {
162
+ e.preventDefault();
163
+ handleBtnClick(scrollPrev);
164
+ };
165
+
166
+ const onNextClick = (e: MouseEvent) => {
167
+ e.preventDefault();
168
+ handleBtnClick(scrollNext);
169
+ };
74
170
 
75
171
  if (prevBtn) prevBtn.addEventListener("click", onPrevClick);
76
172
  if (nextBtn) nextBtn.addEventListener("click", onNextClick);
77
173
 
78
- let intervalId: any = null;
79
- if (autoplay) {
80
- intervalId = setInterval(() => {
81
- embla.scrollNext();
82
- }, 4000);
83
- }
174
+ // Pointer events for drag/swipe
175
+ const onPointerDown = (e: PointerEvent) => {
176
+ if (isTransitioning) return;
177
+ stopAutoplay();
178
+ startX = e.clientX;
179
+ startY = e.clientY;
180
+ isDragging = true;
181
+ hasDragged = false;
182
+ try {
183
+ viewport.setPointerCapture(e.pointerId);
184
+ } catch (err) {}
185
+ };
186
+
187
+ const onPointerMove = (e: PointerEvent) => {
188
+ if (!isDragging) return;
189
+ const deltaX = e.clientX - startX;
190
+ const deltaY = e.clientY - startY;
191
+
192
+ if (!hasDragged) {
193
+ // If vertical movement is greater than horizontal early on, cancel drag to let page scroll
194
+ if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > 5) {
195
+ isDragging = false;
196
+ try {
197
+ viewport.releasePointerCapture(e.pointerId);
198
+ } catch (err) {}
199
+ return;
200
+ }
201
+ if (Math.abs(deltaX) > 5) {
202
+ hasDragged = true;
203
+ container.style.transition = 'none'; // Only disable transition when we actually start dragging
204
+ }
205
+ }
206
+
207
+ if (hasDragged) {
208
+ e.preventDefault();
209
+ const width = viewport.clientWidth;
210
+ const deltaPercent = (deltaX / width) * 100;
211
+ const basePercent = -currentIndex * 100;
212
+ updateTransform(false, basePercent + deltaPercent);
213
+ }
214
+ };
215
+
216
+ const onPointerUp = (e: PointerEvent) => {
217
+ if (!isDragging) return;
218
+ isDragging = false;
219
+ try {
220
+ viewport.releasePointerCapture(e.pointerId);
221
+ } catch (err) {}
222
+
223
+ if (hasDragged) {
224
+ const deltaX = e.clientX - startX;
225
+ const width = viewport.clientWidth;
226
+ const threshold = width * 0.15;
227
+
228
+ if (deltaX < -threshold) {
229
+ scrollNext();
230
+ } else if (deltaX > threshold) {
231
+ scrollPrev();
232
+ } else {
233
+ // Snap back
234
+ isTransitioning = true;
235
+ updateTransform(true);
236
+ setTimeout(() => {
237
+ isTransitioning = false;
238
+ }, 50);
239
+ }
240
+ } else {
241
+ // If it was just a click, make sure transition settings are restored
242
+ container.style.transition = 'transform 500ms cubic-bezier(0.16, 1, 0.3, 1)';
243
+ isTransitioning = false;
244
+ }
245
+ startAutoplay();
246
+ };
247
+
248
+ viewport.addEventListener("pointerdown", onPointerDown);
249
+ viewport.addEventListener("pointermove", onPointerMove, { passive: false });
250
+ viewport.addEventListener("pointerup", onPointerUp);
251
+ viewport.addEventListener("pointercancel", onPointerUp);
252
+ container.addEventListener("transitionend", handleTransitionEnd);
84
253
 
85
- this.emblaCleanup = () => {
254
+ this.cleanup = () => {
255
+ stopAutoplay();
86
256
  if (prevBtn) prevBtn.removeEventListener("click", onPrevClick);
87
257
  if (nextBtn) nextBtn.removeEventListener("click", onNextClick);
88
- if (intervalId) clearInterval(intervalId);
89
- embla.destroy();
258
+ viewport.removeEventListener("pointerdown", onPointerDown);
259
+ viewport.removeEventListener("pointermove", onPointerMove);
260
+ viewport.removeEventListener("pointerup", onPointerUp);
261
+ viewport.removeEventListener("pointercancel", onPointerUp);
262
+ container.removeEventListener("transitionend", handleTransitionEnd);
263
+
264
+ if (container.contains(firstClone)) container.removeChild(firstClone);
265
+ if (container.contains(lastClone)) container.removeChild(lastClone);
90
266
  };
91
267
  }
92
268
 
93
269
  disconnectedCallback() {
94
- if (this.emblaCleanup) {
95
- this.emblaCleanup();
96
- this.emblaCleanup = null;
270
+ if (this.cleanup) {
271
+ this.cleanup();
272
+ this.cleanup = null;
97
273
  }
98
274
  }
99
275
  }
@@ -1,44 +1,42 @@
1
1
  ---
2
- import RLAButton from "./RLAButton.astro"
3
2
  import type { LinkProps } from "../../types"
4
3
 
5
4
  const {
6
5
  to,
7
6
  href,
8
- variant = "link",
9
- color = "neutral",
10
7
  external,
11
- isButton = false,
12
8
  ariaLabel,
13
9
  externalIcon,
14
10
  target,
15
11
  rel,
16
12
  trailingIcon,
13
+ noExternalIcon,
14
+ class: className,
17
15
  ...rest
18
- } = Astro.props as LinkProps
16
+ } = Astro.props as LinkProps & { noExternalIcon?: boolean }
19
17
 
20
18
  const linkHref = href || (typeof to === 'string' ? to : undefined);
21
19
 
22
20
  const isExternal = !!(external || (linkHref && (linkHref.startsWith('http://') || linkHref.startsWith('https://') || linkHref.startsWith('//'))));
23
21
 
24
- const linkVariant = isButton && variant === "link" ? "solid" : variant;
25
22
  const linkTarget = target || (isExternal ? "_blank" : undefined);
26
23
  const linkRel = rel || (isExternal ? "noopener noreferrer" : undefined);
27
24
 
28
- const showExternalIcon = isExternal && !trailingIcon ? (externalIcon || "i-lucide-external-link") : trailingIcon;
25
+ const showExternalIcon = isExternal && !noExternalIcon && !trailingIcon ? (externalIcon || "i-lucide-external-link") : trailingIcon;
29
26
  ---
30
- <RLAButton
31
- variant={linkVariant as any}
32
- color={color as any}
27
+ <a
33
28
  href={linkHref}
34
29
  target={linkTarget}
35
30
  rel={linkRel}
36
31
  aria-label={ariaLabel}
37
- trailingIcon={showExternalIcon}
32
+ class={className}
38
33
  {...rest}
39
34
  >
40
35
  <slot />
36
+ {showExternalIcon && (
37
+ <span class={showExternalIcon} aria-hidden="true" />
38
+ )}
41
39
  {isExternal && !ariaLabel && (
42
40
  <span class="sr-only"> (opens in a new tab)</span>
43
41
  )}
44
- </RLAButton>
42
+ </a>
@@ -49,7 +49,8 @@ function getLocaleDisplayName(localeCode: string): string {
49
49
  }
50
50
 
51
51
  const items = processedLocales.map((item) => {
52
- const targetUrl = getRelativeLocaleUrl(item.code, Astro.url.pathname.replace(/^\/[^/]+/, ''));
52
+ const basePath = Astro.url.pathname.replace(/^\/[^/]+/, '') || '/';
53
+ const targetUrl = getRelativeLocaleUrl(item.code, basePath) + Astro.url.search;
53
54
  return {
54
55
  label: item.label !== item.code ? item.label : getLocaleDisplayName(item.code),
55
56
  value: targetUrl,
@@ -21,16 +21,110 @@ const {
21
21
  const classes = resolveClasses(popover, {
22
22
  placement
23
23
  }, useUi("popover", uiProp), className)
24
- const popoverId = `rla-popover-${Math.random().toString(36).substring(2, 9)}`
25
24
  ---
26
- <div class={classes.root} data-slot="popover-container" {...rest}>
27
- <div class={classes.trigger} data-slot="trigger">
25
+ <div class={classes.root} data-slot="popover-container" data-placement={placement} {...rest}>
26
+ <div class={`${classes.trigger} popover-trigger`} data-slot="trigger">
28
27
  <slot name="trigger">
29
- {triggerLabel && <button type="button" class="cursor-pointer" popovertarget={popoverId}>{triggerLabel}</button>}
28
+ {triggerLabel && <button type="button" class="cursor-pointer">{triggerLabel}</button>}
30
29
  </slot>
31
30
  </div>
32
31
 
33
- <div id={popoverId} popover class={classes.content} data-slot="content">
32
+ <div class={`${classes.content} popover-content hidden`} data-slot="content">
34
33
  <slot />
35
34
  </div>
36
35
  </div>
36
+
37
+ <script>
38
+ function initPopovers() {
39
+ document.querySelectorAll('[data-slot="popover-container"]').forEach(container => {
40
+ const el = container as any;
41
+ if (el.dataset && el.dataset.popoverInitialized) return;
42
+ if (el.dataset) el.dataset.popoverInitialized = "true";
43
+
44
+ const trigger = container.querySelector('.popover-trigger');
45
+ const content = container.querySelector('.popover-content') as HTMLElement;
46
+
47
+ if (!trigger || !content) return;
48
+
49
+ trigger.addEventListener('click', (e) => {
50
+ e.stopPropagation();
51
+ const isOpen = !content.classList.contains('hidden');
52
+
53
+ document.querySelectorAll('.popover-content').forEach(otherContent => {
54
+ const other = otherContent as HTMLElement;
55
+ if (other !== content && !other.classList.contains('hidden')) {
56
+ other.classList.add('hidden');
57
+ other.style.left = '';
58
+ other.style.right = '';
59
+ other.style.transform = '';
60
+ other.dispatchEvent(new CustomEvent('popover-toggle', { detail: { open: false } }));
61
+ }
62
+ });
63
+
64
+ if (isOpen) {
65
+ content.classList.add('hidden');
66
+ content.style.left = '';
67
+ content.style.right = '';
68
+ content.style.transform = '';
69
+ content.dispatchEvent(new CustomEvent('popover-toggle', { detail: { open: false } }));
70
+ } else {
71
+ content.classList.remove('hidden');
72
+
73
+ // Reset styles to measure original computed bounds
74
+ content.style.left = '';
75
+ content.style.right = '';
76
+ content.style.transform = '';
77
+
78
+ const rect = content.getBoundingClientRect();
79
+ const viewportWidth = window.innerWidth;
80
+ const placement = container.getAttribute('data-placement') || 'bottom';
81
+
82
+ let shift = 0;
83
+ const safetyMargin = 24;
84
+ if (rect.right > viewportWidth - safetyMargin) {
85
+ shift = (viewportWidth - safetyMargin) - rect.right;
86
+ } else if (rect.left < safetyMargin) {
87
+ shift = safetyMargin - rect.left;
88
+ }
89
+
90
+ if (shift !== 0) {
91
+ const isCentered = (placement === 'top' || placement === 'bottom') &&
92
+ !content.classList.contains('right-0') &&
93
+ !content.classList.contains('left-0');
94
+ if (isCentered) {
95
+ content.style.transform = `translateX(calc(-50% + ${shift}px))`;
96
+ } else {
97
+ content.style.transform = `translateX(${shift}px)`;
98
+ }
99
+ }
100
+
101
+ content.dispatchEvent(new CustomEvent('popover-toggle', { detail: { open: true } }));
102
+ }
103
+ });
104
+
105
+ content.addEventListener('click', (e) => {
106
+ e.stopPropagation();
107
+ });
108
+ });
109
+ }
110
+
111
+ const win = window as any;
112
+ if (!win.hasGlobalPopoverListener) {
113
+ win.hasGlobalPopoverListener = true;
114
+ document.addEventListener('click', () => {
115
+ document.querySelectorAll('.popover-content').forEach(content => {
116
+ const c = content as HTMLElement;
117
+ if (!c.classList.contains('hidden')) {
118
+ c.classList.add('hidden');
119
+ c.style.left = '';
120
+ c.style.right = '';
121
+ c.style.transform = '';
122
+ c.dispatchEvent(new CustomEvent('popover-toggle', { detail: { open: false } }));
123
+ }
124
+ });
125
+ });
126
+ }
127
+
128
+ document.addEventListener('astro:page-load', initPopovers);
129
+ initPopovers();
130
+ </script>