@orangesk/orange-design-system 2.0.0-beta.42 → 2.0.0-beta.44

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 (70) hide show
  1. package/build/components/AnchorNavigation/style.css +1 -1
  2. package/build/components/AnchorNavigation/style.css.map +1 -1
  3. package/build/components/BlockAction/style.css +1 -1
  4. package/build/components/BlockAction/style.css.map +1 -1
  5. package/build/components/BodyBanner/style.css +1 -1
  6. package/build/components/BodyBanner/style.css.map +1 -1
  7. package/build/components/Breadcrumbs/style.css +1 -1
  8. package/build/components/Breadcrumbs/style.css.map +1 -1
  9. package/build/components/Carousel/style.css +1 -1
  10. package/build/components/Carousel/style.css.map +1 -1
  11. package/build/components/Expander/style.css +1 -1
  12. package/build/components/Expander/style.css.map +1 -1
  13. package/build/components/Footer/style.css +1 -1
  14. package/build/components/Footer/style.css.map +1 -1
  15. package/build/components/Grid/style.css +1 -1
  16. package/build/components/Grid/style.css.map +1 -1
  17. package/build/components/Megamenu/style.css +1 -1
  18. package/build/components/Megamenu/style.css.map +1 -1
  19. package/build/components/Tag/style.css +1 -1
  20. package/build/components/Tag/style.css.map +1 -1
  21. package/build/components/index.js +1 -1
  22. package/build/components/index.js.map +1 -1
  23. package/build/components/tsconfig.tsbuildinfo +1 -1
  24. package/build/components/types/index.d.ts +4 -2
  25. package/build/components/types/src/components/AnchorNavigation/AnchorNavigation.static.d.ts +27 -0
  26. package/build/components/types/src/components/Carousel/Carousel.static.d.ts +7 -5
  27. package/build/components/types/src/components/Expander/Expander.d.ts +2 -0
  28. package/build/components/types/src/components/Grid/Grid.d.ts +2 -2
  29. package/build/components/types/src/components/Megamenu/Megamenu.static.d.ts +3 -0
  30. package/build/components/types/src/components/Modal/Modal.static.d.ts +1 -0
  31. package/build/lib/base.css +1 -1
  32. package/build/lib/base.css.map +1 -1
  33. package/build/lib/components.css +1 -1
  34. package/build/lib/components.css.map +1 -1
  35. package/build/lib/footer.css +1 -1
  36. package/build/lib/footer.css.map +1 -1
  37. package/build/lib/megamenu.css +1 -1
  38. package/build/lib/megamenu.css.map +1 -1
  39. package/build/lib/megamenu.js +1 -1
  40. package/build/lib/megamenu.js.map +1 -1
  41. package/build/lib/scripts.js +1 -1
  42. package/build/lib/scripts.js.map +1 -1
  43. package/build/lib/style.css +1 -1
  44. package/build/lib/style.css.map +1 -1
  45. package/build/lib/tsconfig.tsbuildinfo +1 -1
  46. package/build/search-index.json +2 -2
  47. package/package.json +13 -13
  48. package/src/components/AnchorNavigation/AnchorNavigation.static.ts +247 -40
  49. package/src/components/AnchorNavigation/styles/mixins.scss +8 -0
  50. package/src/components/AnchorNavigation/tests/AnchorNavigation.unit.test.jsx +72 -0
  51. package/src/components/BlockAction/styles/mixins.scss +1 -1
  52. package/src/components/BodyBanner/styles/mixins.scss +2 -12
  53. package/src/components/Carousel/Carousel.static.ts +110 -79
  54. package/src/components/Carousel/styles/mixins.scss +2 -2
  55. package/src/components/Carousel/tests/Carousel.static.test.jsx +203 -0
  56. package/src/components/Expander/Expander.tsx +4 -0
  57. package/src/components/Expander/styles/style.scss +12 -0
  58. package/src/components/Expander/tests/Expander.conformance.test.jsx +4 -0
  59. package/src/components/Expander/tests/Expander.unit.test.jsx +9 -0
  60. package/src/components/Grid/Grid.tsx +5 -2
  61. package/src/components/Grid/styles/config.scss +3 -2
  62. package/src/components/Grid/tests/Grid.unit.test.jsx +40 -10
  63. package/src/components/Megamenu/Megamenu.static.ts +27 -1
  64. package/src/components/Megamenu/styles/mixins.scss +4 -0
  65. package/src/components/Modal/Modal.static.ts +29 -7
  66. package/src/components/Tag/styles/config.scss +5 -1
  67. package/src/components/Tag/styles/mixins.scss +2 -1
  68. package/src/styles/base/globals.scss +1 -0
  69. package/src/styles/export/base.js +1 -1
  70. package/src/styles/tokens/base.scss +1 -1
@@ -32,6 +32,7 @@
32
32
  margin-bottom: 0 !important;
33
33
  padding: convert.to-rem(25px) 0 !important;
34
34
  white-space: nowrap;
35
+ color: inherit;
35
36
  text-decoration: none !important;
36
37
  display: inline-block;
37
38
  cursor: pointer;
@@ -39,6 +40,13 @@
39
40
  line-height: convert.to-rem(20px) !important;
40
41
  font-weight: 700 !important;
41
42
 
43
+ &:link,
44
+ &:visited,
45
+ &:active {
46
+ color: inherit;
47
+ text-decoration: none !important;
48
+ }
49
+
42
50
  &:last-child {
43
51
  margin-right: 0;
44
52
  }
@@ -603,6 +603,78 @@ describe("rendering AnchorNavigation", () => {
603
603
  section.remove();
604
604
  });
605
605
 
606
+ it("prefers document scroll-padding-top over internal sticky offset", () => {
607
+ const section = document.createElement("section");
608
+ section.id = "pricing";
609
+ Object.defineProperty(section, "getBoundingClientRect", {
610
+ configurable: true,
611
+ value: () => ({
612
+ top: 600 - window.scrollY,
613
+ bottom: 700 - window.scrollY,
614
+ left: 0,
615
+ right: 0,
616
+ width: 0,
617
+ height: 100,
618
+ x: 0,
619
+ y: 600,
620
+ toJSON: () => ({}),
621
+ }),
622
+ });
623
+ document.body.appendChild(section);
624
+
625
+ const megamenu = document.createElement("div");
626
+ megamenu.setAttribute("data-megamenu", "");
627
+ Object.defineProperty(megamenu, "offsetHeight", {
628
+ configurable: true,
629
+ value: 120,
630
+ });
631
+ document.body.appendChild(megamenu);
632
+
633
+ Object.defineProperty(window, "scrollY", {
634
+ configurable: true,
635
+ value: 0,
636
+ writable: true,
637
+ });
638
+
639
+ const previousScrollPaddingTop =
640
+ document.documentElement.style.scrollPaddingTop;
641
+ document.documentElement.style.scrollPaddingTop = "210px";
642
+
643
+ const scrollToSpy = vi
644
+ .spyOn(window, "scrollTo")
645
+ .mockImplementation((options) => {
646
+ if (typeof options === "object" && typeof options.top === "number") {
647
+ window.scrollY = options.top;
648
+ }
649
+ });
650
+
651
+ const { container } = render(<AnchorNavigation items={basicItems} />);
652
+ const anchorNavigationElement = initializeAnchorNavigation(container);
653
+ const anchorNavigation = AnchorNavigationStatic.getInstance(
654
+ anchorNavigationElement,
655
+ );
656
+ const pricingLink = container.querySelector('a[href="#pricing"]');
657
+
658
+ Object.defineProperty(anchorNavigationElement, "offsetHeight", {
659
+ configurable: true,
660
+ value: 50,
661
+ });
662
+
663
+ fireEvent.click(pricingLink);
664
+
665
+ expect(scrollToSpy).toHaveBeenNthCalledWith(1, {
666
+ top: 390,
667
+ behavior: "smooth",
668
+ });
669
+
670
+ anchorNavigation?.destroy();
671
+ scrollToSpy.mockRestore();
672
+ document.documentElement.style.scrollPaddingTop =
673
+ previousScrollPaddingTop;
674
+ megamenu.remove();
675
+ section.remove();
676
+ });
677
+
606
678
  it("realigns anchor after sticky offset changes during smooth scroll", () => {
607
679
  vi.useFakeTimers();
608
680
 
@@ -28,5 +28,5 @@
28
28
 
29
29
  @mixin border {
30
30
  outline: 2px solid var(--color-border-accent);
31
- outline-offset: -1px;
31
+ outline-offset: -2px;
32
32
  }
@@ -42,7 +42,7 @@
42
42
  width: 100%;
43
43
  display: flex;
44
44
  padding: space.get("large");
45
- gap: convert.to-rem(30px);
45
+ gap: convert.to-rem(60px);
46
46
 
47
47
  > div:not(.body-banner__button) {
48
48
  flex: 1;
@@ -56,6 +56,7 @@
56
56
  @include breakpoint.get("lg", "down") {
57
57
  display: flex;
58
58
  flex-direction: column;
59
+ gap: convert.to-rem(30px);
59
60
  }
60
61
 
61
62
  @include breakpoint.get("sm", "down") {
@@ -82,13 +83,7 @@
82
83
  @mixin button {
83
84
  display: flex;
84
85
  align-items: center;
85
- padding: 0 0 0 space.get("xlarge");
86
86
  margin: 0 !important;
87
-
88
- @include breakpoint.get("lg", "down") {
89
- padding: 0;
90
- margin-top: space.get("small") !important;
91
- }
92
87
  }
93
88
 
94
89
  @mixin large {
@@ -113,9 +108,4 @@
113
108
  display: block;
114
109
  }
115
110
  }
116
-
117
- .body-banner__button {
118
- padding: 0;
119
- margin-top: space.get("small") !important;
120
- }
121
111
  }
@@ -54,12 +54,14 @@ export const defaultConfig: SwiperOptions = {
54
54
  hide: false,
55
55
  },
56
56
  slidesPerView: 1.2,
57
+ spaceBetween: 20,
57
58
  mousewheel: {
58
59
  forceToAxis: true,
59
60
  sensitivity: 1,
60
61
  },
61
62
  a11y: {
62
63
  enabled: true,
64
+ scrollOnFocus: false,
63
65
  prevSlideMessage: "Predchádzajúci snímok",
64
66
  nextSlideMessage: "Nasledujúci snímok",
65
67
  paginationBulletMessage: "Prejsť na snímok {index}",
@@ -96,6 +98,76 @@ export default class Carousel {
96
98
  private resizeRafId?: number;
97
99
  private boundWindowResizeHandler: () => void;
98
100
  private bleedResizeHandler?: () => void;
101
+ private static readonly OVERFLOW_EPSILON_PX = 1;
102
+
103
+ private getViewportWrapper(): HTMLElement | null {
104
+ return this.element.querySelector(
105
+ `.${CLASS_VIEWPORT_WRAPPER}`,
106
+ ) as HTMLElement | null;
107
+ }
108
+
109
+ private getSlidesPerView(): number {
110
+ return Number(this.instance?.params?.slidesPerView) || 1;
111
+ }
112
+
113
+ private setScrollbarVisible(visible: boolean): void {
114
+ const scrollbarEl = this.element.querySelector(
115
+ SELECTOR_SCROLLBAR,
116
+ ) as HTMLElement | null;
117
+ if (scrollbarEl) {
118
+ scrollbarEl.style.display = visible ? "" : "none";
119
+ }
120
+ }
121
+
122
+ private getSlidesContentWidth(): number {
123
+ if (!this.instance) {
124
+ return this.track?.scrollWidth || 0;
125
+ }
126
+
127
+ const slides = Array.from(this.instance.slides) as HTMLElement[];
128
+ const widthsSum = slides.reduce((sum, slide) => sum + slide.offsetWidth, 0);
129
+ const spaceBetween = Number(this.instance.params?.spaceBetween) || 0;
130
+ const gaps = Math.max(slides.length - 1, 0) * spaceBetween;
131
+ const measuredWidth = widthsSum + gaps;
132
+
133
+ // Fallback for early lifecycle/test DOM where slides can report 0 width.
134
+ if (widthsSum <= 0 || measuredWidth <= 0) {
135
+ return this.track?.scrollWidth || 0;
136
+ }
137
+
138
+ return measuredWidth;
139
+ }
140
+
141
+ private getOverflowWidth(viewportWrapper?: HTMLElement): number {
142
+ const isBleedRight = this.element.classList.contains(CLASS_BLEED_RIGHT);
143
+ const containerWidth = isBleedRight
144
+ ? viewportWrapper?.clientWidth || 0
145
+ : this.viewport?.clientWidth || 0;
146
+
147
+ if (containerWidth <= 0) {
148
+ return 0;
149
+ }
150
+
151
+ const contentWidth = this.getSlidesContentWidth();
152
+
153
+ return contentWidth - containerWidth;
154
+ }
155
+
156
+ private hasScrollableContent(viewportWrapper?: HTMLElement): boolean {
157
+ if (!this.instance || !this.instance.params) {
158
+ return false;
159
+ }
160
+
161
+ const overflowWidth = this.getOverflowWidth(viewportWrapper);
162
+ if (overflowWidth !== 0) {
163
+ return overflowWidth > Carousel.OVERFLOW_EPSILON_PX;
164
+ }
165
+
166
+ // Fallback for hidden/zero-width initialization.
167
+ const slidesCount = this.instance.slides.length;
168
+ const slidesPerView = this.getSlidesPerView();
169
+ return slidesCount > slidesPerView;
170
+ }
99
171
 
100
172
  constructor(element: HTMLElement, config?: Partial<SwiperOptions>) {
101
173
  this.element = element;
@@ -130,8 +202,12 @@ export default class Carousel {
130
202
  this.getCustomOptions();
131
203
  }
132
204
 
205
+ const isBleedRight = this.element.classList.contains(CLASS_BLEED_RIGHT);
206
+
133
207
  this.instance = new Swiper(this.viewport, {
134
208
  ...this.config,
209
+ // Swiper watchOverflow can mis-detect with slides offsets; manage bleed-right overflow ourselves.
210
+ watchOverflow: isBleedRight ? false : this.config.watchOverflow,
135
211
  enabled: false,
136
212
  modules: [Navigation, Pagination, Scrollbar, A11y, Keyboard, Mousewheel],
137
213
  on: {
@@ -236,9 +312,7 @@ export default class Carousel {
236
312
  */
237
313
  fixBleedRightScrollbar() {
238
314
  const updateScrollbar = () => {
239
- const viewportWrapper = this.element.querySelector(
240
- `.${CLASS_VIEWPORT_WRAPPER}`,
241
- ) as HTMLElement;
315
+ const viewportWrapper = this.getViewportWrapper();
242
316
  const scrollbar = this.instance?.scrollbar;
243
317
  const swiper = this.instance;
244
318
 
@@ -246,26 +320,18 @@ export default class Carousel {
246
320
  return;
247
321
  }
248
322
 
249
- const minTranslate = swiper.minTranslate();
250
- const maxTranslate = swiper.maxTranslate();
251
- const translateRange = Math.abs(maxTranslate - minTranslate);
323
+ const hasScrollableContent = this.hasScrollableContent(viewportWrapper);
252
324
 
253
- if (translateRange <= 1 || !swiper.enabled) {
254
- if (scrollbar.el) {
255
- scrollbar.el.style.display = "none";
256
- }
325
+ if (!hasScrollableContent) {
326
+ this.setScrollbarVisible(false);
257
327
  return;
258
328
  }
259
329
 
260
- if (scrollbar.el) {
261
- scrollbar.el.style.display = "";
262
- }
330
+ this.setScrollbarVisible(true);
263
331
 
264
332
  const scrollbarWidth = scrollbar.el.offsetWidth;
265
333
  const viewportWidth = viewportWrapper.clientWidth;
266
- const offsetBefore = Number(swiper.params.slidesOffsetBefore) || 0;
267
- const offsetAfter = Number(swiper.params.slidesOffsetAfter) || 0;
268
- const contentWidth = this.track.scrollWidth + offsetBefore + offsetAfter;
334
+ const contentWidth = this.getSlidesContentWidth();
269
335
 
270
336
  const visibleRatio =
271
337
  contentWidth > 0 ? Math.min(viewportWidth / contentWidth, 1) : 1;
@@ -347,40 +413,18 @@ export default class Carousel {
347
413
  private updateCarouselEnabledState(): void {
348
414
  if (!this.instance || !this.instance.params) return;
349
415
 
350
- const slidesCount = this.instance.slides.length;
351
- const slidesPerView = Number(this.instance.params.slidesPerView) || 1;
416
+ const viewportWrapper = this.getViewportWrapper();
417
+ const hasScrollableContent = this.hasScrollableContent(
418
+ viewportWrapper || undefined,
419
+ );
352
420
 
353
- if (slidesCount > slidesPerView) {
421
+ if (hasScrollableContent) {
354
422
  this.instance.enable();
355
423
  } else {
356
424
  this.instance.disable();
357
425
  }
358
426
 
359
- const scrollbarEl = this.element.querySelector(
360
- SELECTOR_SCROLLBAR,
361
- ) as HTMLElement | null;
362
- if (scrollbarEl) {
363
- scrollbarEl.style.display = this.instance.enabled ? "" : "none";
364
- }
365
-
366
- this.applyTrackMarginCompensation();
367
- }
368
-
369
- /**
370
- * Apply negative margin-right to carousel track when all slides fit on screen.
371
- * This compensates for the 20px padding-right on each slide, ensuring proper grid alignment.
372
- */
373
- private applyTrackMarginCompensation(): void {
374
- if (!this.instance || !this.instance.params || !this.viewport) return;
375
-
376
- const slidesPerView = Number(this.instance.params.slidesPerView) || 1;
377
- const slidesCount = this.instance.slides.length;
378
-
379
- if (slidesCount <= slidesPerView) {
380
- this.viewport.style.setProperty("margin-right", "-20px", "important");
381
- } else {
382
- this.viewport.style.removeProperty("margin-right");
383
- }
427
+ this.setScrollbarVisible(this.instance.enabled);
384
428
  }
385
429
 
386
430
  private applyBleedInsets(viewportWrapper: HTMLElement): void {
@@ -388,42 +432,34 @@ export default class Carousel {
388
432
  return;
389
433
  }
390
434
 
391
- const viewportWidth =
392
- document.documentElement.clientWidth ||
393
- window.visualViewport?.width ||
394
- window.innerWidth;
395
-
396
- const rect = viewportWrapper.getBoundingClientRect();
397
- const insetLeft = Math.max(Math.round(rect.left), 0);
398
- const trailingSlidePadding = 20;
399
- const insetAfter = Math.max(insetLeft - trailingSlidePadding, 0);
400
-
401
- const slidesPerView = Number(this.instance.params.slidesPerView) || 1;
435
+ const projectedContentWidth = this.getSlidesContentWidth();
436
+ const projectedOverflowWidth =
437
+ projectedContentWidth - viewportWrapper.clientWidth;
438
+ const slidesPerView = this.getSlidesPerView();
402
439
  const slidesCount = this.instance.slides.length;
403
- const shouldDisableBleed =
404
- viewportWidth >= 2560 || slidesCount <= slidesPerView;
440
+ const hasIntrinsicOverflow =
441
+ projectedContentWidth > 0
442
+ ? projectedOverflowWidth > Carousel.OVERFLOW_EPSILON_PX
443
+ : slidesCount > slidesPerView;
444
+ const shouldDisableBleed = !hasIntrinsicOverflow;
405
445
 
406
446
  this.element.classList.toggle(CLASS_BLEED_RIGHT, !shouldDisableBleed);
407
447
 
408
- if (shouldDisableBleed) {
409
- this.instance.params.slidesOffsetBefore = 0;
410
- this.instance.params.slidesOffsetAfter = 0;
411
- this.element.style.setProperty("--carousel-bleed-viewport-width", "100%");
412
- this.element.style.setProperty("--carousel-bleed-margin-left", "0px");
413
- } else {
414
- this.instance.params.slidesOffsetBefore = insetLeft;
415
- this.instance.params.slidesOffsetAfter = insetAfter;
416
- this.element.style.setProperty(
417
- "--carousel-bleed-viewport-width",
418
- "100dvw",
419
- );
420
- this.element.style.setProperty(
421
- "--carousel-bleed-margin-left",
422
- "calc(50% - 50dvw)",
423
- );
448
+ const baseWidth = shouldDisableBleed
449
+ ? undefined
450
+ : viewportWrapper.clientWidth;
451
+ this.instance.params.width = baseWidth;
452
+ if (this.instance.originalParams) {
453
+ this.instance.originalParams.width = baseWidth;
424
454
  }
425
455
 
456
+ this.instance.params.slidesOffsetBefore = 0;
457
+ this.instance.params.slidesOffsetAfter = 0;
458
+ this.element.style.setProperty("--carousel-bleed-viewport-width", "100%");
459
+ this.element.style.setProperty("--carousel-bleed-margin-left", "0px");
460
+
426
461
  this.instance.update();
462
+ this.updateCarouselEnabledState();
427
463
  }
428
464
 
429
465
  /**
@@ -436,16 +472,11 @@ export default class Carousel {
436
472
  requestAnimationFrame(() => {
437
473
  if (!this.instance) return;
438
474
 
439
- const viewportWrapper = this.element.querySelector(
440
- `.${CLASS_VIEWPORT_WRAPPER}`,
441
- ) as HTMLElement;
475
+ const viewportWrapper = this.getViewportWrapper();
442
476
  if (!viewportWrapper) return;
443
477
 
444
478
  const updateBleedState = () => {
445
479
  this.applyBleedInsets(viewportWrapper);
446
-
447
- // Update margin compensation on resize
448
- this.applyTrackMarginCompensation();
449
480
  };
450
481
 
451
482
  this.bleedResizeHandler = updateBleedState;
@@ -105,7 +105,7 @@
105
105
  flex-direction: column;
106
106
  max-width: 100%;
107
107
  flex: 0 0 auto;
108
- padding: 0 convert.to-rem(20px) 0 0;
108
+ padding: 0;
109
109
  user-select: none;
110
110
 
111
111
  > * {
@@ -225,6 +225,6 @@
225
225
  max-width: var(--carousel-bleed-viewport-width, 100%);
226
226
  margin-right: 0;
227
227
  margin-left: var(--carousel-bleed-margin-left, 0) !important;
228
- overflow: hidden !important;
228
+ overflow: visible !important;
229
229
  }
230
230
  }
@@ -124,6 +124,12 @@ describe("Carousel Static - External Controls", () => {
124
124
  });
125
125
 
126
126
  describe("External Controls Initialization", () => {
127
+ it("should disable a11y scrollOnFocus to prevent focus-triggered slide jumps", () => {
128
+ const swiperConfig = Swiper.mock.calls[0]?.[1];
129
+
130
+ expect(swiperConfig?.a11y?.scrollOnFocus).toBe(false);
131
+ });
132
+
127
133
  it("should find and initialize external controls", () => {
128
134
  const prevButton = document.getElementById("prev-btn");
129
135
  const nextButton = document.getElementById("next-btn");
@@ -644,5 +650,202 @@ describe("Carousel Static - Auto-Disable Feature", () => {
644
650
  carouselInstance.updateCarouselEnabledState();
645
651
  }).not.toThrow();
646
652
  });
653
+
654
+ it("should not apply negative viewport margin on mobile when slides fit", () => {
655
+ const originalInnerWidth = window.innerWidth;
656
+ Object.defineProperty(window, "innerWidth", {
657
+ configurable: true,
658
+ writable: true,
659
+ value: 375,
660
+ });
661
+
662
+ mockSwiperInstance.slides = [document.createElement("div")];
663
+ mockSwiperInstance.params.slidesPerView = 1;
664
+
665
+ carouselInstance = new Carousel(carouselElement);
666
+
667
+ const viewport = carouselElement.querySelector(".carousel__viewport");
668
+ expect(viewport.style.getPropertyValue("margin-right")).toBe("");
669
+
670
+ Object.defineProperty(window, "innerWidth", {
671
+ configurable: true,
672
+ writable: true,
673
+ value: originalInnerWidth,
674
+ });
675
+ });
676
+
677
+ it("should not apply negative viewport margin on desktop when slides fit", () => {
678
+ const originalInnerWidth = window.innerWidth;
679
+ Object.defineProperty(window, "innerWidth", {
680
+ configurable: true,
681
+ writable: true,
682
+ value: 1240,
683
+ });
684
+
685
+ mockSwiperInstance.slides = [document.createElement("div")];
686
+ mockSwiperInstance.params.slidesPerView = 1;
687
+
688
+ carouselInstance = new Carousel(carouselElement);
689
+
690
+ const viewport = carouselElement.querySelector(".carousel__viewport");
691
+ expect(viewport.style.getPropertyValue("margin-right")).toBe("");
692
+
693
+ Object.defineProperty(window, "innerWidth", {
694
+ configurable: true,
695
+ writable: true,
696
+ value: originalInnerWidth,
697
+ });
698
+ });
699
+
700
+ it("should disable bleed-right when slides fit wrapper despite right inset", () => {
701
+ const originalInnerWidth = window.innerWidth;
702
+ Object.defineProperty(window, "innerWidth", {
703
+ configurable: true,
704
+ writable: true,
705
+ value: 1200,
706
+ });
707
+
708
+ mockSwiperInstance.slides = [
709
+ document.createElement("div"),
710
+ document.createElement("div"),
711
+ ];
712
+ mockSwiperInstance.params.slidesPerView = 2;
713
+
714
+ document.body.innerHTML = `
715
+ <div class="carousel carousel--bleed-right" data-carousel-id="test-carousel" id="test-carousel">
716
+ <div class="carousel__viewport-wrapper">
717
+ <div class="carousel__viewport">
718
+ <div class="carousel__track">
719
+ <div class="carousel__slide">Slide 1</div>
720
+ <div class="carousel__slide">Slide 2</div>
721
+ </div>
722
+ </div>
723
+ </div>
724
+ <div class="carousel__pagination"></div>
725
+ </div>
726
+ `;
727
+
728
+ carouselElement = document.querySelector(".carousel");
729
+ const viewportWrapper = carouselElement.querySelector(
730
+ ".carousel__viewport-wrapper",
731
+ );
732
+ const track = carouselElement.querySelector(".carousel__track");
733
+
734
+ Object.defineProperty(viewportWrapper, "clientWidth", {
735
+ configurable: true,
736
+ value: 900,
737
+ });
738
+ Object.defineProperty(track, "scrollWidth", {
739
+ configurable: true,
740
+ value: 900,
741
+ });
742
+ Object.defineProperty(viewportWrapper, "getBoundingClientRect", {
743
+ configurable: true,
744
+ value: () => ({
745
+ top: 0,
746
+ right: 1000,
747
+ bottom: 200,
748
+ left: 100,
749
+ width: 900,
750
+ height: 200,
751
+ x: 100,
752
+ y: 0,
753
+ toJSON: () => ({}),
754
+ }),
755
+ });
756
+
757
+ carouselInstance = new Carousel(carouselElement);
758
+
759
+ expect(mockSwiperInstance.params.slidesOffsetBefore).toBe(0);
760
+ expect(mockSwiperInstance.params.slidesOffsetAfter).toBe(0);
761
+
762
+ Object.defineProperty(window, "innerWidth", {
763
+ configurable: true,
764
+ writable: true,
765
+ value: originalInnerWidth,
766
+ });
767
+ });
768
+
769
+ it("should keep wrapper-based bleed geometry without offsets", () => {
770
+ const originalInnerWidth = window.innerWidth;
771
+ Object.defineProperty(window, "innerWidth", {
772
+ configurable: true,
773
+ writable: true,
774
+ value: 1000,
775
+ });
776
+
777
+ mockSwiperInstance.slides = [
778
+ document.createElement("div"),
779
+ document.createElement("div"),
780
+ document.createElement("div"),
781
+ ];
782
+ mockSwiperInstance.params.slidesPerView = 1.2;
783
+ mockSwiperInstance.params.spaceBetween = 20;
784
+
785
+ document.body.innerHTML = `
786
+ <div class="carousel carousel--bleed-right" data-carousel-id="test-carousel" id="test-carousel">
787
+ <div class="carousel__viewport-wrapper">
788
+ <div class="carousel__viewport">
789
+ <div class="carousel__track">
790
+ <div class="carousel__slide">Slide 1</div>
791
+ <div class="carousel__slide">Slide 2</div>
792
+ <div class="carousel__slide">Slide 3</div>
793
+ </div>
794
+ </div>
795
+ </div>
796
+ <div class="carousel__pagination"></div>
797
+ </div>
798
+ `;
799
+
800
+ carouselElement = document.querySelector(".carousel");
801
+ const viewportWrapper = carouselElement.querySelector(
802
+ ".carousel__viewport-wrapper",
803
+ );
804
+ const track = carouselElement.querySelector(".carousel__track");
805
+
806
+ Object.defineProperty(viewportWrapper, "clientWidth", {
807
+ configurable: true,
808
+ value: 800,
809
+ });
810
+ Object.defineProperty(track, "scrollWidth", {
811
+ configurable: true,
812
+ value: 1200,
813
+ });
814
+
815
+ Object.defineProperty(viewportWrapper, "getBoundingClientRect", {
816
+ configurable: true,
817
+ value: () => ({
818
+ top: 0,
819
+ right: 920,
820
+ bottom: 200,
821
+ left: 120,
822
+ width: 800,
823
+ height: 200,
824
+ x: 120,
825
+ y: 0,
826
+ toJSON: () => ({}),
827
+ }),
828
+ });
829
+
830
+ carouselInstance = new Carousel(carouselElement);
831
+
832
+ expect(mockSwiperInstance.params.slidesOffsetBefore).toBe(0);
833
+ expect(mockSwiperInstance.params.slidesOffsetAfter).toBe(0);
834
+ expect(mockSwiperInstance.params.width).toBe(800);
835
+ expect(
836
+ carouselElement.style.getPropertyValue(
837
+ "--carousel-bleed-viewport-width",
838
+ ),
839
+ ).toBe("100%");
840
+ expect(
841
+ carouselElement.style.getPropertyValue("--carousel-bleed-margin-left"),
842
+ ).toBe("0px");
843
+
844
+ Object.defineProperty(window, "innerWidth", {
845
+ configurable: true,
846
+ writable: true,
847
+ value: originalInnerWidth,
848
+ });
849
+ });
647
850
  });
648
851
  });
@@ -25,6 +25,8 @@ interface ExpanderProps {
25
25
  children?: React.ReactNode;
26
26
  /** Expander takes full width of its container */
27
27
  isFullWidth?: boolean;
28
+ /** Position of trigger relative to content when opened */
29
+ placement?: "top" | "bottom";
28
30
  /** Group identifier for syncing multiple expanders together */
29
31
  toggleGroup?: string;
30
32
  /** Initial open state */
@@ -40,6 +42,7 @@ export const Expander: React.FC<ExpanderProps> = (props) => {
40
42
  renderSummary,
41
43
  renderSummaryOpened,
42
44
  isFullWidth,
45
+ placement = "bottom",
43
46
  toggleGroup,
44
47
  ...other
45
48
  } = props;
@@ -49,6 +52,7 @@ export const Expander: React.FC<ExpanderProps> = (props) => {
49
52
  CLASS_ROOT,
50
53
  {
51
54
  [`${CLASS_ROOT}--fullwidth`]: isFullWidth,
55
+ [`${CLASS_ROOT}--placement-top`]: placement === "top",
52
56
  },
53
57
  className,
54
58
  );