@orangesk/orange-design-system 3.6.2 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/build/components/Alert/style.css +1 -1
  2. package/build/components/Alert/style.css.map +1 -1
  3. package/build/components/Bar/style.css +1 -1
  4. package/build/components/Bar/style.css.map +1 -1
  5. package/build/components/Drawer/style.css +2 -0
  6. package/build/components/Drawer/style.css.map +1 -0
  7. package/build/components/Grid/style.css.map +1 -1
  8. package/build/components/Megamenu/style.css +1 -1
  9. package/build/components/Megamenu/style.css.map +1 -1
  10. package/build/components/Modal/style.css +1 -1
  11. package/build/components/Modal/style.css.map +1 -1
  12. package/build/components/StatusIndicator/style.css +2 -0
  13. package/build/components/StatusIndicator/style.css.map +1 -0
  14. package/build/components/Table/style.css +1 -1
  15. package/build/components/Table/style.css.map +1 -1
  16. package/build/components/index.js +1 -1
  17. package/build/components/index.js.map +1 -1
  18. package/build/components/types/index.d.ts +89 -3
  19. package/build/lib/base.css +1 -1
  20. package/build/lib/base.css.map +1 -1
  21. package/build/lib/components.css +1 -1
  22. package/build/lib/components.css.map +1 -1
  23. package/build/lib/footer.js +1 -1
  24. package/build/lib/footer.js.map +1 -1
  25. package/build/lib/megamenu.css +1 -1
  26. package/build/lib/megamenu.css.map +1 -1
  27. package/build/lib/megamenu.js +1 -1
  28. package/build/lib/megamenu.js.map +1 -1
  29. package/build/lib/scripts.js +1 -1
  30. package/build/lib/scripts.js.map +1 -1
  31. package/build/lib/style.css +1 -1
  32. package/build/lib/style.css.map +1 -1
  33. package/build/lib/utilities.css +1 -1
  34. package/build/lib/utilities.css.map +1 -1
  35. package/package.json +29 -29
  36. package/src/components/Alert/Alert.tsx +2 -2
  37. package/src/components/Alert/data.ts +8 -5
  38. package/src/components/Alert/styles/config.scss +4 -4
  39. package/src/components/Alert/styles/mixins.scss +8 -1
  40. package/src/components/Alert/tests/Alert.test.jsx +11 -1
  41. package/src/components/Bar/Bar.tsx +1 -1
  42. package/src/components/Bar/styles/config.scss +1 -0
  43. package/src/components/Bar/tests/Bar.unit.test.jsx +1 -1
  44. package/src/components/Carousel/Carousel.static.ts +103 -12
  45. package/src/components/Carousel/tests/Carousel.static.test.jsx +134 -0
  46. package/src/components/DocumentationSidebar/DocumentationSidebar.tsx +12 -4
  47. package/src/components/Drawer/Drawer.static.ts +281 -0
  48. package/src/components/Drawer/Drawer.tsx +82 -0
  49. package/src/components/Drawer/DrawerBody.tsx +21 -0
  50. package/src/components/Drawer/DrawerFooter.tsx +21 -0
  51. package/src/components/Drawer/DrawerHeader.tsx +21 -0
  52. package/src/components/Drawer/index.ts +5 -0
  53. package/src/components/Drawer/styles/config.scss +17 -0
  54. package/src/components/Drawer/styles/mixins.scss +119 -0
  55. package/src/components/Drawer/styles/style.scss +71 -0
  56. package/src/components/Drawer/tests/Drawer.unit.test.jsx +149 -0
  57. package/src/components/Footer/data.ts +4 -0
  58. package/src/components/Forms/SegmentedControl/SegmentedControl.tsx +122 -0
  59. package/src/components/Forms/SegmentedControl/index.ts +5 -0
  60. package/src/components/Forms/SegmentedControl/styles/mixins.scss +52 -0
  61. package/src/components/Forms/SegmentedControl/styles/style.scss +35 -0
  62. package/src/components/Forms/SegmentedControl/tests/SegmentedControl.conformance.test.jsx +36 -0
  63. package/src/components/Forms/SegmentedControl/tests/SegmentedControl.unit.test.jsx +125 -0
  64. package/src/components/Forms/SelectionTile/PLAN.md +206 -0
  65. package/src/components/Forms/SelectionTile/SelectionTile.tsx +145 -0
  66. package/src/components/Forms/SelectionTile/index.ts +5 -0
  67. package/src/components/Forms/SelectionTile/styles/mixins.scss +83 -0
  68. package/src/components/Forms/SelectionTile/styles/style.scss +58 -0
  69. package/src/components/Forms/SelectionTile/tests/SelectionTile.conformance.test.jsx +32 -0
  70. package/src/components/Forms/SelectionTile/tests/SelectionTile.unit.test.jsx +146 -0
  71. package/src/components/Forms/index.ts +2 -0
  72. package/src/components/Icon/index.ts +1 -0
  73. package/src/components/Megamenu/Megamenu.tsx +20 -16
  74. package/src/components/Megamenu/MegamenuBlog.tsx +10 -7
  75. package/src/components/Megamenu/MyOrangeMobilePanel.tsx +2 -3
  76. package/src/components/Megamenu/constants.ts +3 -1
  77. package/src/components/Megamenu/styles/mixins.scss +53 -18
  78. package/src/components/Megamenu/styles/style.scss +12 -4
  79. package/src/components/Megamenu/tests/Megamenu.unit.test.jsx +32 -0
  80. package/src/components/Modal/styles/config.scss +3 -2
  81. package/src/components/StatusIndicator/StatusIndicator.tsx +29 -0
  82. package/src/components/StatusIndicator/index.ts +2 -0
  83. package/src/components/StatusIndicator/styles/config.scss +12 -0
  84. package/src/components/StatusIndicator/styles/mixins.scss +36 -0
  85. package/src/components/StatusIndicator/styles/style.scss +16 -0
  86. package/src/components/StatusIndicator/tests/StatusIndicator.conformance.test.jsx +22 -0
  87. package/src/components/StatusIndicator/tests/StatusIndicator.unit.test.jsx +70 -0
  88. package/src/components/Table/Table.static.ts +23 -10
  89. package/src/components/Table/Table.tsx +7 -2
  90. package/src/components/Table/styles/mixins.scss +59 -0
  91. package/src/components/Table/styles/style.scss +25 -0
  92. package/src/components/Table/tests/Table.static.unit.test.jsx +72 -0
  93. package/src/components/Table/tests/Table.unit.test.jsx +33 -0
  94. package/src/components/index.ts +13 -0
  95. package/src/styles/tokens/overlay.scss +3 -0
  96. package/src/styles/typography/style.scss +0 -13
  97. package/src/styles/utilities/border.scss +4 -0
  98. package/src/styles/utilities/border.styles.unit.test.jsx +9 -0
  99. package/src/styles/utilities/text.scss +12 -0
  100. package/src/components/Carousel/CHANGELOG.md +0 -11
@@ -241,6 +241,9 @@ export default class Carousel {
241
241
  modules: [Navigation, Pagination, Scrollbar, A11y, Keyboard, Mousewheel],
242
242
  on: {
243
243
  slideChange: this.handleSlideChange,
244
+ snapIndexChange: () => {
245
+ this.updateExternalControlsState();
246
+ },
244
247
  slideChangeTransitionEnd: () => {
245
248
  this.updateExternalControlsState();
246
249
  },
@@ -680,6 +683,9 @@ export default class Carousel {
680
683
  }
681
684
 
682
685
  this.carouselId = carouselId;
686
+ if (!this.element.id) {
687
+ this.element.id = carouselId;
688
+ }
683
689
 
684
690
  const controlElements = document.querySelectorAll(
685
691
  `[data-carousel-controls="${carouselId}"]`,
@@ -698,8 +704,22 @@ export default class Carousel {
698
704
  (htmlControl as any)._carouselClickHandler,
699
705
  );
700
706
  }
707
+ if ((htmlControl as any)._carouselKeydownHandler) {
708
+ htmlControl.removeEventListener(
709
+ "keydown",
710
+ (htmlControl as any)._carouselKeydownHandler,
711
+ );
712
+ }
701
713
 
702
714
  const action = htmlControl.dataset.carouselAction;
715
+ const slideIndexValue = htmlControl.dataset.carouselSlideIndex;
716
+ const slideIndex =
717
+ slideIndexValue !== undefined ? Number(slideIndexValue) : undefined;
718
+ const hasValidSlideIndex =
719
+ !action &&
720
+ slideIndex !== undefined &&
721
+ Number.isInteger(slideIndex) &&
722
+ slideIndex >= 0;
703
723
 
704
724
  const clickHandler = (e: Event) => {
705
725
  e.preventDefault();
@@ -710,23 +730,51 @@ export default class Carousel {
710
730
  this.slideNext();
711
731
  } else if (action === "prev") {
712
732
  this.slidePrev();
733
+ } else if (hasValidSlideIndex) {
734
+ this.goToSlide(slideIndex);
735
+ }
736
+ };
737
+
738
+ const keydownHandler = (e: KeyboardEvent) => {
739
+ if (e.key !== "Enter" && e.key !== " ") {
740
+ return;
713
741
  }
742
+
743
+ clickHandler(e);
714
744
  };
715
745
 
716
- if (action === "next" || action === "prev") {
746
+ if (action === "next" || action === "prev" || hasValidSlideIndex) {
717
747
  htmlControl.addEventListener("click", clickHandler);
718
748
 
719
- htmlControl.setAttribute(
720
- "aria-label",
721
- action === "next"
722
- ? (typeof this.config.a11y === "object"
723
- ? this.config.a11y?.nextSlideMessage
724
- : undefined) || "Nasledujúci snímok"
725
- : (typeof this.config.a11y === "object"
726
- ? this.config.a11y?.prevSlideMessage
727
- : undefined) || "Predchádzajúci snímok",
728
- );
729
- htmlControl.setAttribute("type", "button");
749
+ htmlControl.setAttribute("aria-controls", this.element.id);
750
+
751
+ if (action === "next" || action === "prev") {
752
+ if (!htmlControl.hasAttribute("aria-label")) {
753
+ htmlControl.setAttribute(
754
+ "aria-label",
755
+ action === "next"
756
+ ? (typeof this.config.a11y === "object"
757
+ ? this.config.a11y?.nextSlideMessage
758
+ : undefined) || "Nasledujúci snímok"
759
+ : (typeof this.config.a11y === "object"
760
+ ? this.config.a11y?.prevSlideMessage
761
+ : undefined) || "Predchádzajúci snímok",
762
+ );
763
+ }
764
+ }
765
+
766
+ if (htmlControl instanceof HTMLButtonElement) {
767
+ htmlControl.setAttribute("type", "button");
768
+ } else {
769
+ if (!htmlControl.hasAttribute("role")) {
770
+ htmlControl.setAttribute("role", "button");
771
+ }
772
+ if (!htmlControl.hasAttribute("tabindex")) {
773
+ htmlControl.setAttribute("tabindex", "0");
774
+ }
775
+ htmlControl.addEventListener("keydown", keydownHandler);
776
+ (htmlControl as any)._carouselKeydownHandler = keydownHandler;
777
+ }
730
778
 
731
779
  (htmlControl as any)._carouselClickHandler = clickHandler;
732
780
  }
@@ -773,6 +821,9 @@ export default class Carousel {
773
821
  const nextControls = document.querySelectorAll(
774
822
  `[data-carousel-controls="${this.carouselId}"][data-carousel-action="next"]`,
775
823
  );
824
+ const slideControls = document.querySelectorAll(
825
+ `[data-carousel-controls="${this.carouselId}"][data-carousel-slide-index]:not([data-carousel-action])`,
826
+ );
776
827
 
777
828
  prevControls.forEach((control) => {
778
829
  const htmlControl = control as HTMLElement;
@@ -799,6 +850,25 @@ export default class Carousel {
799
850
  htmlControl.style.cursor = "pointer";
800
851
  }
801
852
  });
853
+
854
+ const activeIndex = this.instance.params.loop
855
+ ? (this.instance.realIndex ?? this.instance.activeIndex ?? 0)
856
+ : (this.instance.snapIndex ?? this.instance.activeIndex ?? 0);
857
+ slideControls.forEach((control) => {
858
+ const htmlControl = control as HTMLElement;
859
+ const slideIndex = Number(htmlControl.dataset.carouselSlideIndex);
860
+ if (
861
+ !Number.isInteger(slideIndex) ||
862
+ slideIndex < 0 ||
863
+ slideIndex >= this.instance.slides.length
864
+ ) {
865
+ return;
866
+ }
867
+ const isActive = slideIndex === activeIndex;
868
+
869
+ htmlControl.classList.toggle(CLASS_ACTIVE, isActive);
870
+ htmlControl.setAttribute("aria-pressed", String(isActive));
871
+ });
802
872
  }
803
873
 
804
874
  /**
@@ -819,6 +889,27 @@ export default class Carousel {
819
889
  }
820
890
  }
821
891
 
892
+ /**
893
+ * Navigate to a specific slide index from an external control.
894
+ */
895
+ goToSlide(index: number) {
896
+ if (
897
+ !this.instance ||
898
+ !Number.isInteger(index) ||
899
+ index < 0 ||
900
+ index >= this.instance.slides.length
901
+ ) {
902
+ return;
903
+ }
904
+
905
+ if (this.instance.params.loop && this.instance.slideToLoop) {
906
+ this.instance.slideToLoop(index);
907
+ return;
908
+ }
909
+
910
+ this.instance.slideTo(index);
911
+ }
912
+
822
913
  /**
823
914
  * Get the current active slide index.
824
915
  */
@@ -12,6 +12,7 @@ vi.mock("swiper", () => ({
12
12
  return {
13
13
  slideNext: vi.fn(),
14
14
  slidePrev: vi.fn(),
15
+ slideTo: vi.fn(),
15
16
  destroy: vi.fn(),
16
17
  update: vi.fn(),
17
18
  on: vi.fn(),
@@ -64,6 +65,7 @@ describe("Carousel Static - External Controls", () => {
64
65
  mockSwiperInstance = {
65
66
  slideNext: vi.fn(),
66
67
  slidePrev: vi.fn(),
68
+ slideTo: vi.fn(),
67
69
  destroy: vi.fn(),
68
70
  update: vi.fn(),
69
71
  on: vi.fn(),
@@ -142,6 +144,21 @@ describe("Carousel Static - External Controls", () => {
142
144
  expect(nextButton.getAttribute("aria-label")).toBe("Nasledujúci snímok");
143
145
  expect(prevButton.getAttribute("type")).toBe("button");
144
146
  expect(nextButton.getAttribute("type")).toBe("button");
147
+ expect(prevButton.getAttribute("aria-controls")).toBe("test-carousel");
148
+ expect(nextButton.getAttribute("aria-controls")).toBe("test-carousel");
149
+ });
150
+
151
+ it("should preserve an explicit accessible label", () => {
152
+ const customNext = document.createElement("button");
153
+ customNext.dataset.carouselControls = "test-carousel";
154
+ customNext.dataset.carouselAction = "next";
155
+ customNext.setAttribute("aria-label", "Ďalší produkt");
156
+ document.body.appendChild(customNext);
157
+
158
+ carouselInstance.initExternalControls();
159
+
160
+ expect(customNext).toHaveAttribute("aria-label", "Ďalší produkt");
161
+ expect(customNext).toHaveAttribute("aria-controls", "test-carousel");
145
162
  });
146
163
 
147
164
  it("should not initialize controls without carousel ID", () => {
@@ -178,6 +195,123 @@ describe("Carousel Static - External Controls", () => {
178
195
  });
179
196
 
180
197
  describe("External Controls Navigation", () => {
198
+ it("should navigate by slide index and synchronize index controls", () => {
199
+ document.body.insertAdjacentHTML(
200
+ "beforeend",
201
+ `
202
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="0" id="slide-0">XL</button>
203
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="1" id="slide-1">M</button>
204
+ `,
205
+ );
206
+ carouselInstance.initExternalControls();
207
+
208
+ const firstControl = document.getElementById("slide-0");
209
+ const secondControl = document.getElementById("slide-1");
210
+ expect(firstControl).toHaveClass("is-active");
211
+ expect(firstControl).toHaveAttribute("aria-pressed", "true");
212
+ expect(secondControl).toHaveAttribute("aria-pressed", "false");
213
+
214
+ secondControl.click();
215
+ expect(mockSwiperInstance.slideTo).toHaveBeenCalledWith(1);
216
+
217
+ mockSwiperInstance.activeIndex = 1;
218
+ const swiperConfig = Swiper.mock.calls[0]?.[1];
219
+ swiperConfig?.on?.slideChange?.();
220
+
221
+ expect(firstControl).not.toHaveClass("is-active");
222
+ expect(firstControl).toHaveAttribute("aria-pressed", "false");
223
+ expect(secondControl).toHaveClass("is-active");
224
+ expect(secondControl).toHaveAttribute("aria-pressed", "true");
225
+ });
226
+
227
+ it("should make non-native index controls keyboard accessible", () => {
228
+ document.body.insertAdjacentHTML(
229
+ "beforeend",
230
+ `<span data-carousel-controls="test-carousel" data-carousel-slide-index="1" id="keyboard-slide">M</span>`,
231
+ );
232
+ carouselInstance.initExternalControls();
233
+
234
+ const control = document.getElementById("keyboard-slide");
235
+ expect(control).toHaveAttribute("role", "button");
236
+ expect(control).toHaveAttribute("tabindex", "0");
237
+ expect(control).toHaveAttribute("aria-controls", "test-carousel");
238
+
239
+ control.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
240
+ control.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
241
+
242
+ expect(mockSwiperInstance.slideTo).toHaveBeenCalledTimes(2);
243
+ expect(mockSwiperInstance.slideTo).toHaveBeenNthCalledWith(1, 1);
244
+ expect(mockSwiperInstance.slideTo).toHaveBeenNthCalledWith(2, 1);
245
+ });
246
+
247
+ it("should ignore index controls with an invalid slide index", () => {
248
+ document.body.insertAdjacentHTML(
249
+ "beforeend",
250
+ `<button data-carousel-controls="test-carousel" data-carousel-slide-index="nope" id="invalid-slide">Invalid</button>`,
251
+ );
252
+ carouselInstance.initExternalControls();
253
+
254
+ document.getElementById("invalid-slide").click();
255
+
256
+ expect(mockSwiperInstance.slideTo).not.toHaveBeenCalled();
257
+ expect(document.getElementById("invalid-slide")).not.toHaveAttribute(
258
+ "aria-pressed",
259
+ );
260
+ });
261
+
262
+ it("should highlight the last index control when the carousel reaches the end", () => {
263
+ document.body.insertAdjacentHTML(
264
+ "beforeend",
265
+ `
266
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="0" id="end-slide-0">XL</button>
267
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="1" id="end-slide-1">M</button>
268
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="2" id="end-slide-2">S</button>
269
+ `,
270
+ );
271
+ carouselInstance.initExternalControls();
272
+
273
+ mockSwiperInstance.activeIndex = 1;
274
+ mockSwiperInstance.realIndex = 1;
275
+ mockSwiperInstance.snapIndex = 2;
276
+ mockSwiperInstance.isEnd = true;
277
+ carouselInstance.updateExternalControlsState();
278
+
279
+ expect(document.getElementById("end-slide-2")).toHaveClass("is-active");
280
+ expect(document.getElementById("end-slide-2")).toHaveAttribute(
281
+ "aria-pressed",
282
+ "true",
283
+ );
284
+ });
285
+
286
+ it("should synchronize index controls when only the snap index changes", () => {
287
+ document.body.insertAdjacentHTML(
288
+ "beforeend",
289
+ `
290
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="0" id="snap-slide-0">XL</button>
291
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="1" id="snap-slide-1">M</button>
292
+ <button data-carousel-controls="test-carousel" data-carousel-slide-index="2" id="snap-slide-2">S</button>
293
+ `,
294
+ );
295
+ carouselInstance.initExternalControls();
296
+ const swiperConfig = Swiper.mock.calls[0]?.[1];
297
+
298
+ mockSwiperInstance.activeIndex = 1;
299
+ mockSwiperInstance.realIndex = 1;
300
+ mockSwiperInstance.snapIndex = 2;
301
+ mockSwiperInstance.isEnd = true;
302
+ swiperConfig?.on?.slideChange?.();
303
+ expect(document.getElementById("snap-slide-2")).toHaveClass("is-active");
304
+
305
+ mockSwiperInstance.snapIndex = 1;
306
+ mockSwiperInstance.isEnd = false;
307
+ swiperConfig?.on?.snapIndexChange?.();
308
+
309
+ expect(document.getElementById("snap-slide-1")).toHaveClass("is-active");
310
+ expect(document.getElementById("snap-slide-2")).not.toHaveClass(
311
+ "is-active",
312
+ );
313
+ });
314
+
181
315
  it("should call slideNext when next button is clicked", () => {
182
316
  const nextButton = document.getElementById("next-btn");
183
317
  nextButton.click();
@@ -9,13 +9,19 @@ import packageJson from "../../../package.json";
9
9
  import { TextInput } from "../Forms/TextInput";
10
10
  import { Icon } from "../Icon";
11
11
  import { Image } from "../Image";
12
+ import type { TagColor } from "../Tag";
12
13
  import { Tag } from "../Tag";
13
14
 
15
+ export interface DocumentationSidebarTag {
16
+ text: string;
17
+ color?: TagColor;
18
+ }
19
+
14
20
  export interface DocumentationSidebarItem {
15
21
  label: string;
16
22
  href?: string;
17
23
  target?: React.HTMLAttributeAnchorTarget;
18
- tag?: string;
24
+ tag?: DocumentationSidebarTag;
19
25
  items?: DocumentationSidebarItem[];
20
26
  content?: string; // Optional: MDX content for searching
21
27
  }
@@ -207,11 +213,11 @@ const SidebarItem: React.FC<{
207
213
  <span>{item.label}</span>
208
214
  {item.tag ? (
209
215
  <Tag
210
- color="warning"
216
+ color={item.tag.color || "warning"}
211
217
  size="small"
212
218
  className={`${CLASS_ROOT}__tag`}
213
219
  >
214
- {item.tag}
220
+ {item.tag.text}
215
221
  </Tag>
216
222
  ) : null}
217
223
  </span>
@@ -392,7 +398,9 @@ export const DocumentationSidebar: React.FC<DocumentationSidebarProps> = (
392
398
  aria-label="Sidebar navigation"
393
399
  ref={sidebarRef}
394
400
  >
395
- <Image src="/logo.svg" alt="logo" />
401
+ <Link href="/">
402
+ <Image src="/logo.svg" alt="logo" />
403
+ </Link>
396
404
 
397
405
  <div className="mb">
398
406
  <TextInput
@@ -0,0 +1,281 @@
1
+ import A11yDialog from "a11y-dialog";
2
+ import Modal from "../Modal/Modal.static";
3
+
4
+ interface DrawerConfig {
5
+ classDrawerIsOpen: string;
6
+ drawersRoot: string;
7
+ disablePortal?: boolean;
8
+ }
9
+
10
+ const defaultConfig = (): DrawerConfig => ({
11
+ classDrawerIsOpen: "is-active",
12
+ drawersRoot: "#root-drawers",
13
+ });
14
+
15
+ const SWIPE_DISTANCE = 80;
16
+ const SWIPE_VELOCITY = 0.5;
17
+
18
+ interface HTMLElementWithDrawer extends HTMLElement {
19
+ ODS_Drawer?: Drawer;
20
+ }
21
+
22
+ /** Accessible vanilla bottom-sheet drawer backed by a11y-dialog. */
23
+ export default class Drawer {
24
+ element: HTMLElementWithDrawer;
25
+ config: DrawerConfig;
26
+ instance!: A11yDialog;
27
+ private gestureStart: { x: number; y: number; time: number } | null = null;
28
+ private gestureTarget: HTMLElement | null = null;
29
+ private useTouchGestures = false;
30
+
31
+ constructor(element: HTMLElement, config?: Partial<DrawerConfig>) {
32
+ this.element = element as HTMLElementWithDrawer;
33
+ this.config = { ...defaultConfig(), ...config };
34
+
35
+ this.handleShow = this.handleShow.bind(this);
36
+ this.handleHide = this.handleHide.bind(this);
37
+ this.show = this.show.bind(this);
38
+ this.hide = this.hide.bind(this);
39
+ this.handlePointerDown = this.handlePointerDown.bind(this);
40
+ this.handlePointerMove = this.handlePointerMove.bind(this);
41
+ this.handlePointerUp = this.handlePointerUp.bind(this);
42
+ this.handleTouchStart = this.handleTouchStart.bind(this);
43
+ this.handleTouchMove = this.handleTouchMove.bind(this);
44
+ this.handleTouchEnd = this.handleTouchEnd.bind(this);
45
+
46
+ this.element.ODS_Drawer = this;
47
+ this.init();
48
+ }
49
+
50
+ static getInstance(element: HTMLElement | null): Drawer | null {
51
+ if (!element) return null;
52
+ return (element as HTMLElementWithDrawer).ODS_Drawer || null;
53
+ }
54
+
55
+ handleShow(element: HTMLElement): void {
56
+ Modal.lockBody();
57
+ element.classList.add(this.config.classDrawerIsOpen);
58
+ }
59
+
60
+ handleHide(element: HTMLElement): void {
61
+ this.resetGesture();
62
+ Modal.unlockBody();
63
+ element.classList.remove(this.config.classDrawerIsOpen);
64
+ }
65
+
66
+ show(): void {
67
+ this.instance.show();
68
+ }
69
+
70
+ hide(): void {
71
+ this.instance.hide();
72
+ }
73
+
74
+ init(): void {
75
+ if (this.config.drawersRoot && !this.config.disablePortal) {
76
+ const container = document.querySelector(this.config.drawersRoot);
77
+ if (container) {
78
+ container.appendChild(this.element);
79
+ } else {
80
+ // biome-ignore lint/suspicious/noConsole: missing portal root is an integration diagnostic
81
+ console.warn(
82
+ `\`${this.config.drawersRoot}\` element is not present in DOM. Drawer will be placed inside its current parent. Please provide \`${this.config.drawersRoot}\` selector outside of the main content.`,
83
+ );
84
+ }
85
+ }
86
+
87
+ this.instance = new A11yDialog(this.element);
88
+ this.instance.on("show", (event: Event) => {
89
+ this.handleShow(event.currentTarget as HTMLElement);
90
+ });
91
+ this.instance.on("hide", (event: Event) => {
92
+ this.handleHide(event.currentTarget as HTMLElement);
93
+ });
94
+
95
+ const panel = this.element.querySelector<HTMLElement>(".drawer__panel");
96
+ const overlay = this.element.querySelector<HTMLElement>(".drawer__overlay");
97
+ this.useTouchGestures = navigator.maxTouchPoints > 0;
98
+ [panel, overlay].forEach((target) => {
99
+ if (this.useTouchGestures) {
100
+ target?.addEventListener("touchstart", this.handleTouchStart, {
101
+ passive: true,
102
+ });
103
+ target?.addEventListener("touchmove", this.handleTouchMove, {
104
+ passive: false,
105
+ });
106
+ target?.addEventListener("touchend", this.handleTouchEnd);
107
+ target?.addEventListener("touchcancel", this.handleTouchEnd);
108
+ } else {
109
+ target?.addEventListener("pointerdown", this.handlePointerDown);
110
+ target?.addEventListener("pointermove", this.handlePointerMove);
111
+ target?.addEventListener("pointerup", this.handlePointerUp);
112
+ target?.addEventListener("pointercancel", this.handlePointerUp);
113
+ }
114
+ });
115
+ }
116
+
117
+ private isDesktop(): boolean {
118
+ return window.innerWidth >= 768;
119
+ }
120
+
121
+ private handlePointerDown(event: PointerEvent): void {
122
+ if (event.isPrimary === false) return;
123
+ this.gestureStart = {
124
+ x: event.clientX,
125
+ y: event.clientY,
126
+ time: Date.now(),
127
+ };
128
+ this.gestureTarget = event.currentTarget as HTMLElement;
129
+ this.gestureTarget.setPointerCapture?.(event.pointerId);
130
+ }
131
+
132
+ private canStartTouchGesture(event: TouchEvent): boolean {
133
+ const touch = event.touches[0];
134
+ if (!touch) return false;
135
+
136
+ const body = this.element.querySelector<HTMLElement>(".drawer__body");
137
+ const target = event.target as Node | null;
138
+ if (body?.contains(target) && body.scrollTop > 0) return false;
139
+ return true;
140
+ }
141
+
142
+ private handleTouchStart(event: TouchEvent): void {
143
+ if (!this.canStartTouchGesture(event)) return;
144
+ const touch = event.touches[0];
145
+ this.gestureStart = {
146
+ x: touch.clientX,
147
+ y: touch.clientY,
148
+ time: Date.now(),
149
+ };
150
+ }
151
+
152
+ private handlePointerMove(event: PointerEvent): void {
153
+ if (!this.gestureStart) return;
154
+ const deltaX = event.clientX - this.gestureStart.x;
155
+ const deltaY = event.clientY - this.gestureStart.y;
156
+ const delta = this.isDesktop() ? deltaX : deltaY;
157
+ const crossAxisDelta = this.isDesktop() ? deltaY : deltaX;
158
+ if (delta <= 0 || Math.abs(crossAxisDelta) > Math.abs(delta)) {
159
+ this.resetGesture();
160
+ return;
161
+ }
162
+
163
+ event.preventDefault();
164
+ const property = this.isDesktop() ? "translateX" : "translateY";
165
+ const panel = this.element.querySelector<HTMLElement>(".drawer__panel");
166
+ if (panel) {
167
+ panel.style.transition = "none";
168
+ panel.style.transform = `${property}(${delta}px)`;
169
+ }
170
+ }
171
+
172
+ private handlePointerUp(event: PointerEvent): void {
173
+ if (!this.gestureStart) return;
174
+ const deltaX = event.clientX - this.gestureStart.x;
175
+ const deltaY = event.clientY - this.gestureStart.y;
176
+ const delta = this.isDesktop() ? deltaX : deltaY;
177
+ const crossAxisDelta = this.isDesktop() ? deltaY : deltaX;
178
+ const elapsed = Math.max(Date.now() - this.gestureStart.time, 1);
179
+ const velocity = delta / elapsed;
180
+ const shouldClose =
181
+ delta > 0 &&
182
+ Math.abs(crossAxisDelta) <= Math.abs(delta) &&
183
+ (delta >= SWIPE_DISTANCE || (delta >= 20 && velocity >= SWIPE_VELOCITY));
184
+
185
+ this.resetGesture();
186
+ if (shouldClose) this.hide();
187
+ }
188
+
189
+ private handleTouchMove(event: TouchEvent): void {
190
+ if (!this.gestureStart) return;
191
+ const touch = event.touches[0];
192
+ if (!touch) return;
193
+
194
+ const deltaX = touch.clientX - this.gestureStart.x;
195
+ const deltaY = touch.clientY - this.gestureStart.y;
196
+ const delta = this.isDesktop() ? deltaX : deltaY;
197
+ const crossAxisDelta = this.isDesktop() ? deltaY : deltaX;
198
+ if (delta <= 0 || Math.abs(crossAxisDelta) > Math.abs(delta)) {
199
+ this.resetGesture();
200
+ return;
201
+ }
202
+
203
+ event.preventDefault();
204
+ this.updateGestureTransform(delta);
205
+ }
206
+
207
+ private handleTouchEnd(event: TouchEvent): void {
208
+ if (!this.gestureStart) return;
209
+ const touch = event.changedTouches[0];
210
+ if (!touch) return;
211
+
212
+ this.finishGesture(touch.clientX, touch.clientY);
213
+ }
214
+
215
+ private updateGestureTransform(delta: number): void {
216
+ const property = this.isDesktop() ? "translateX" : "translateY";
217
+ const panel = this.element.querySelector<HTMLElement>(".drawer__panel");
218
+ if (panel) {
219
+ panel.style.transition = "none";
220
+ panel.style.transform = `${property}(${delta}px)`;
221
+ }
222
+ }
223
+
224
+ private finishGesture(clientX: number, clientY: number): void {
225
+ if (!this.gestureStart) return;
226
+ const deltaX = clientX - this.gestureStart.x;
227
+ const deltaY = clientY - this.gestureStart.y;
228
+ const delta = this.isDesktop() ? deltaX : deltaY;
229
+ const crossAxisDelta = this.isDesktop() ? deltaY : deltaX;
230
+ const elapsed = Math.max(Date.now() - this.gestureStart.time, 1);
231
+ const velocity = delta / elapsed;
232
+ const shouldClose =
233
+ delta > 0 &&
234
+ Math.abs(crossAxisDelta) <= Math.abs(delta) &&
235
+ (delta >= SWIPE_DISTANCE || (delta >= 20 && velocity >= SWIPE_VELOCITY));
236
+
237
+ this.resetGesture();
238
+ if (shouldClose) this.hide();
239
+ }
240
+
241
+ private resetGesture(): void {
242
+ const panel = this.element.querySelector<HTMLElement>(".drawer__panel");
243
+ if (panel) {
244
+ panel.style.transition = "";
245
+ panel.style.transform = "";
246
+ }
247
+ this.gestureStart = null;
248
+ this.gestureTarget = null;
249
+ }
250
+
251
+ destroy(): void {
252
+ if (!this.instance) return;
253
+
254
+ this.instance.hide();
255
+ const dialog = this.instance as any;
256
+ document.removeEventListener("click", dialog.handleTriggerClicks, true);
257
+ document.body.removeEventListener("focus", dialog.maintainFocus, true);
258
+ this.element.removeEventListener("keydown", dialog.bindKeypress, true);
259
+ const panel = this.element.querySelector<HTMLElement>(".drawer__panel");
260
+ const overlay = this.element.querySelector<HTMLElement>(".drawer__overlay");
261
+ [panel, overlay].forEach((target) => {
262
+ if (this.useTouchGestures) {
263
+ target?.removeEventListener("touchstart", this.handleTouchStart);
264
+ target?.removeEventListener("touchmove", this.handleTouchMove);
265
+ target?.removeEventListener("touchend", this.handleTouchEnd);
266
+ target?.removeEventListener("touchcancel", this.handleTouchEnd);
267
+ } else {
268
+ target?.removeEventListener("pointerdown", this.handlePointerDown);
269
+ target?.removeEventListener("pointermove", this.handlePointerMove);
270
+ target?.removeEventListener("pointerup", this.handlePointerUp);
271
+ target?.removeEventListener("pointercancel", this.handlePointerUp);
272
+ }
273
+ });
274
+ Modal.unlockBody();
275
+ }
276
+
277
+ update(): void {
278
+ this.destroy();
279
+ this.init();
280
+ }
281
+ }