From 597021571e3af44a0977c1af83b8bd346ff60211 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Mon, 9 Feb 2026 17:40:44 +0400 Subject: [PATCH 01/15] Implementation --- src/components/carousel.tsx | 17 +++++++++-- src/components/product-card.tsx | 34 ++++++++++++++++++--- src/index.ts | 7 ++++- src/utils/events.ts | 54 +++++++++++++++++++++++++++++++++ src/utils/index.ts | 1 + 5 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 src/utils/events.ts diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index cc57c6f..f5df76d 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -9,7 +9,7 @@ import React, { import useEmblaCarousel from 'embla-carousel-react'; import Autoplay from 'embla-carousel-autoplay'; -import { cn, RenderPropsWrapper } from '@/utils'; +import { cn, RenderPropsWrapper, dispatchCioEvent, CIO_EVENTS } from '@/utils'; import Button from '@/components/button'; import { useCarouselResponsive } from '@/hooks/useCarouselResponsive'; import { useCarouselTweenOpacity } from '@/hooks/useCarouselTweenOpacity'; @@ -310,7 +310,20 @@ function CarouselNavButton({ const isPrevious = direction === 'previous'; const canScroll = isPrevious ? canScrollPrev : canScrollNext; - const handleClick = isPrevious ? scrollPrev : scrollNext; + const scrollFn = isPrevious ? scrollPrev : scrollNext; + + const handleClick = useCallback(() => { + const eventName = isPrevious ? CIO_EVENTS.carousel.previous : CIO_EVENTS.carousel.next; + + dispatchCioEvent(eventName, { + direction, + canScrollNext: canScrollNext ?? false, + canScrollPrev: canScrollPrev ?? false, + }); + + scrollFn?.(); + }, [isPrevious, direction, canScrollNext, canScrollPrev, scrollFn]); + const override = isPrevious ? componentOverrides?.previous?.reactNode : componentOverrides?.next?.reactNode; diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 9f1c7db..2db5b14 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useContext } from 'react'; -import { cn, RenderPropsWrapper } from '@/utils'; +import React, { createContext, useCallback, useContext } from 'react'; +import { cn, RenderPropsWrapper, dispatchCioEvent, CIO_EVENTS } from '@/utils'; import { Card, CardContentProps, CardFooterProps } from '@/components/card'; import Button from '@/components/button'; import BadgeComponent from '@/components/badge'; @@ -160,9 +160,20 @@ const ImageSection: React.FC = (props) => { // Use props with fallback to context values const imageUrl = props.imageUrl || contextImageUrl; + const handleMouseEnter = useCallback(() => { + dispatchCioEvent(CIO_EVENTS.productCard.imageEnter, { product: renderProps.product }); + }, [renderProps.product]); + + const handleMouseLeave = useCallback(() => { + dispatchCioEvent(CIO_EVENTS.productCard.imageLeave, { product: renderProps.product }); + }, [renderProps.product]); + return ( -
+
{name = (props) => { children, } = props; + const handleAddToCartClick = useCallback( + (e: React.MouseEvent) => { + dispatchCioEvent(CIO_EVENTS.productCard.conversion, { product: renderProps.product }); + onAddToCart?.(e); + }, + [renderProps.product, onAddToCart], + ); + return ( = (props) => { props.className, )} conversionType='add_to_cart' - onClick={onAddToCart}> + onClick={handleAddToCartClick}> {addToCartText} )} @@ -350,6 +369,11 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod ...restProps } = props; + const handleProductClick = useCallback(() => { + dispatchCioEvent(CIO_EVENTS.productCard.click, { product }); + onProductClick?.(); + }, [product, onProductClick]); + const renderPropFn = typeof children === 'function' && children; // Default layout when no children provided or render prop function @@ -361,7 +385,7 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod 'cio-product-card min-w-[176px] max-w-[256px] h-full cursor-pointer border-0', className, )} - onClick={onProductClick} + onClick={handleProductClick} {...getProductCardDataAttributes(product)} {...restProps}> diff --git a/src/index.ts b/src/index.ts index b310ab0..eeb0fe2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,16 @@ export { default as Button } from '@/components/button'; export { default as Badge } from '@/components/badge'; export { default as ProductCard } from '@/components/product-card'; export { default as Carousel } from '@/components/carousel'; -export { RenderPropsWrapper } from '@/utils'; +export { RenderPropsWrapper, CIO_EVENTS, dispatchCioEvent } from '@/utils'; // Hooks // Types +export type { + ProductCardEventDetail, + CarouselNavEventDetail, + CioEventDetailMap, +} from '@/utils/events'; export type { ButtonVariants, ButtonOverrides, ButtonProps } from '@/components/button'; export type { BadgeVariants, BadgeOverrides, BadgeProps } from '@/components/badge'; export * from '@/types'; diff --git a/src/utils/events.ts b/src/utils/events.ts new file mode 100644 index 0000000..ff87aa8 --- /dev/null +++ b/src/utils/events.ts @@ -0,0 +1,54 @@ +import type { Product } from '@/types/productCardTypes'; + +/** + * Canonical event name constants for Constructor.io UI component events. + */ +export const CIO_EVENTS = Object.freeze({ + productCard: Object.freeze({ + click: 'cio.components.productCard.click' as const, + conversion: 'cio.components.productCard.conversion' as const, + imageEnter: 'cio.components.productCard.imageEnter' as const, + imageLeave: 'cio.components.productCard.imageLeave' as const, + }), + carousel: Object.freeze({ + next: 'cio.components.carousel.next' as const, + previous: 'cio.components.carousel.previous' as const, + }), +}); + +export interface ProductCardEventDetail { + product: Product; +} + +export interface CarouselNavEventDetail { + direction: 'next' | 'previous'; + canScrollNext: boolean; + canScrollPrev: boolean; +} + +export interface CioEventDetailMap { + [CIO_EVENTS.productCard.click]: ProductCardEventDetail; + [CIO_EVENTS.productCard.conversion]: ProductCardEventDetail; + [CIO_EVENTS.productCard.imageEnter]: ProductCardEventDetail; + [CIO_EVENTS.productCard.imageLeave]: ProductCardEventDetail; + [CIO_EVENTS.carousel.next]: CarouselNavEventDetail; + [CIO_EVENTS.carousel.previous]: CarouselNavEventDetail; +} + +/** + * Dispatches a typed CustomEvent on window for the given CIO event name. + * No-ops in SSR environments where window is undefined. + */ +export function dispatchCioEvent( + eventName: K, + detail: CioEventDetailMap[K], +): void { + if (typeof window === 'undefined') return; + + const event = new CustomEvent(eventName, { + bubbles: true, + cancelable: true, + detail, + }); + window.dispatchEvent(event); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index a7ae071..df1b658 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ /* eslint-disable no-restricted-imports */ export * from './styleHelpers'; +export * from './events'; export { default as RenderPropsWrapper } from './RenderPropsWrapper'; From 0383cd0edb897634dad3aab62189caf8af3d0121 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Mon, 9 Feb 2026 17:40:50 +0400 Subject: [PATCH 02/15] Tests --- spec/components/Carousel/Carousel.test.tsx | 55 +++++++++++ .../product-card/product-card.test.tsx | 99 +++++++++++++++++++ spec/utils/events.test.ts | 74 ++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 spec/utils/events.test.ts diff --git a/spec/components/Carousel/Carousel.test.tsx b/spec/components/Carousel/Carousel.test.tsx index e29a134..5819976 100644 --- a/spec/components/Carousel/Carousel.test.tsx +++ b/spec/components/Carousel/Carousel.test.tsx @@ -4,6 +4,7 @@ import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest'; import CioCarousel from '@/components/carousel'; import { Product } from '@/types/productCardTypes'; import { CarouselRenderProps } from '@/types/carouselTypes'; +import { CIO_EVENTS } from '@/utils/events'; const mockProducts: Product[] = [ { @@ -596,4 +597,58 @@ describe('Carousel component', () => { expect(carousel).toBeInTheDocument(); }); }); + + describe('Pub-Sub Events', () => { + afterEach(() => { + cleanup(); + }); + + test('dispatches carousel.next event when next button is clicked', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.carousel.next, listener); + + render(); + const nextButton = screen.getByRole('button', { name: /next/i }); + fireEvent.click(nextButton); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.direction).toBe('next'); + expect(typeof event.detail.canScrollNext).toBe('boolean'); + expect(typeof event.detail.canScrollPrev).toBe('boolean'); + + window.removeEventListener(CIO_EVENTS.carousel.next, listener); + }); + + test('dispatches carousel.previous event when previous button is clicked', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.carousel.previous, listener); + + render(); + const prevButton = screen.getByRole('button', { name: /previous/i }); + fireEvent.click(prevButton); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.direction).toBe('previous'); + expect(typeof event.detail.canScrollNext).toBe('boolean'); + expect(typeof event.detail.canScrollPrev).toBe('boolean'); + + window.removeEventListener(CIO_EVENTS.carousel.previous, listener); + }); + + test('carousel navigation still works alongside event dispatch', () => { + render(); + + // Buttons should still be present and clickable without errors + const nextButton = screen.getByRole('button', { name: /next/i }); + const prevButton = screen.getByRole('button', { name: /previous/i }); + + expect(() => fireEvent.click(nextButton)).not.toThrow(); + expect(() => fireEvent.click(prevButton)).not.toThrow(); + + // Products should still be rendered + expect(screen.getByText('Product 1')).toBeInTheDocument(); + }); + }); }); diff --git a/spec/components/product-card/product-card.test.tsx b/spec/components/product-card/product-card.test.tsx index 897dafc..139e5b5 100644 --- a/spec/components/product-card/product-card.test.tsx +++ b/spec/components/product-card/product-card.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { describe, test, expect, vi, afterEach } from 'vitest'; import ProductCard from '@/components/product-card'; +import { CIO_EVENTS } from '@/utils/events'; const mockProductData = { product: { @@ -393,4 +394,102 @@ describe('ProductCard component', () => { expect(badgeElement).not.toBeInTheDocument(); }); }); + + describe('Pub-Sub Events', () => { + afterEach(() => { + cleanup(); + }); + + test('dispatches productCard.click event on card click with correct product detail', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + render(); + fireEvent.click(screen.getByTestId('product-card')); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.product).toEqual(mockProductData.product); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('dispatches productCard.click event AND calls onProductClick callback', () => { + const listener = vi.fn(); + const mockOnProductClick = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + render( + , + ); + fireEvent.click(screen.getByTestId('product-card')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(mockOnProductClick).toHaveBeenCalledTimes(1); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('dispatches productCard.click event even without onProductClick prop', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + render(); + fireEvent.click(screen.getByTestId('product-card')); + + expect(listener).toHaveBeenCalledTimes(1); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('dispatches productCard.conversion event on add-to-cart click and calls onAddToCart', () => { + const listener = vi.fn(); + const mockOnAddToCart = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.conversion, listener); + + render(); + fireEvent.click(screen.getByText('Add to Cart')); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.product).toEqual(mockProductData.product); + expect(mockOnAddToCart).toHaveBeenCalledTimes(1); + + window.removeEventListener(CIO_EVENTS.productCard.conversion, listener); + }); + + test('dispatches productCard.imageEnter on mouseEnter of image section', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.imageEnter, listener); + + const { container } = render(); + const imageSection = container.querySelector('.cio-product-card-image-section')!; + fireEvent.mouseEnter(imageSection); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.product).toEqual(mockProductData.product); + + window.removeEventListener(CIO_EVENTS.productCard.imageEnter, listener); + }); + + test('dispatches productCard.imageLeave on mouseLeave of image section', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.imageLeave, listener); + + const { container } = render(); + const imageSection = container.querySelector('.cio-product-card-image-section')!; + fireEvent.mouseLeave(imageSection); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.product).toEqual(mockProductData.product); + + window.removeEventListener(CIO_EVENTS.productCard.imageLeave, listener); + }); + }); }); diff --git a/spec/utils/events.test.ts b/spec/utils/events.test.ts new file mode 100644 index 0000000..1a1ddd2 --- /dev/null +++ b/spec/utils/events.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import { CIO_EVENTS, dispatchCioEvent } from '@/utils/events'; + +describe('Event utility', () => { + describe('CIO_EVENTS constants', () => { + test('productCard event names are correct string literals', () => { + expect(CIO_EVENTS.productCard.click).toBe('cio.components.productCard.click'); + expect(CIO_EVENTS.productCard.conversion).toBe('cio.components.productCard.conversion'); + expect(CIO_EVENTS.productCard.imageEnter).toBe('cio.components.productCard.imageEnter'); + expect(CIO_EVENTS.productCard.imageLeave).toBe('cio.components.productCard.imageLeave'); + }); + + test('carousel event names are correct string literals', () => { + expect(CIO_EVENTS.carousel.next).toBe('cio.components.carousel.next'); + expect(CIO_EVENTS.carousel.previous).toBe('cio.components.carousel.previous'); + }); + + test('CIO_EVENTS object is frozen (immutable)', () => { + expect(Object.isFrozen(CIO_EVENTS)).toBe(true); + expect(Object.isFrozen(CIO_EVENTS.productCard)).toBe(true); + expect(Object.isFrozen(CIO_EVENTS.carousel)).toBe(true); + }); + }); + + describe('dispatchCioEvent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('dispatches CustomEvent on window with correct event name', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + const detail = { product: { id: '1', name: 'Test' } }; + dispatchCioEvent(CIO_EVENTS.productCard.click, detail); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('cio.components.productCard.click'); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('dispatches with correct detail payload', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + const detail = { product: { id: '42', name: 'Widget' } }; + dispatchCioEvent(CIO_EVENTS.productCard.click, detail); + + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail).toEqual(detail); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('dispatches with bubbles and cancelable set to true', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.carousel.next, listener); + + dispatchCioEvent(CIO_EVENTS.carousel.next, { + direction: 'next', + canScrollNext: true, + canScrollPrev: false, + }); + + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.bubbles).toBe(true); + expect(event.cancelable).toBe(true); + + window.removeEventListener(CIO_EVENTS.carousel.next, listener); + }); + }); +}); From cf77ae83287164774c0fd84c69d9c4ae9fa1ba8d Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Mon, 9 Feb 2026 19:23:25 +0400 Subject: [PATCH 03/15] Cleanup --- spec/utils/events.test.ts | 24 ++++++++++++++++++++---- src/components/carousel.tsx | 4 ++-- src/utils/events.ts | 12 +++++++++--- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/spec/utils/events.test.ts b/spec/utils/events.test.ts index 1a1ddd2..213d353 100644 --- a/spec/utils/events.test.ts +++ b/spec/utils/events.test.ts @@ -1,5 +1,8 @@ import { describe, test, expect, vi, afterEach } from 'vitest'; import { CIO_EVENTS, dispatchCioEvent } from '@/utils/events'; +import type { Product } from '@/types/productCardTypes'; + +const mockProduct: Product = { id: '1', name: 'Test Product' }; describe('Event utility', () => { describe('CIO_EVENTS constants', () => { @@ -31,7 +34,7 @@ describe('Event utility', () => { const listener = vi.fn(); window.addEventListener(CIO_EVENTS.productCard.click, listener); - const detail = { product: { id: '1', name: 'Test' } }; + const detail = { product: mockProduct }; dispatchCioEvent(CIO_EVENTS.productCard.click, detail); expect(listener).toHaveBeenCalledTimes(1); @@ -45,7 +48,7 @@ describe('Event utility', () => { const listener = vi.fn(); window.addEventListener(CIO_EVENTS.productCard.click, listener); - const detail = { product: { id: '42', name: 'Widget' } }; + const detail = { product: mockProduct }; dispatchCioEvent(CIO_EVENTS.productCard.click, detail); const event = listener.mock.calls[0][0] as CustomEvent; @@ -54,7 +57,20 @@ describe('Event utility', () => { window.removeEventListener(CIO_EVENTS.productCard.click, listener); }); - test('dispatches with bubbles and cancelable set to true', () => { + test('no-ops without throwing when window is undefined (SSR)', () => { + const originalWindow = globalThis.window; + // @ts-expect-error -- simulating SSR by removing window + delete (globalThis as Record).window; + try { + expect(() => { + dispatchCioEvent(CIO_EVENTS.productCard.click, { product: mockProduct }); + }).not.toThrow(); + } finally { + globalThis.window = originalWindow; + } + }); + + test('dispatches with bubbles false and cancelable true', () => { const listener = vi.fn(); window.addEventListener(CIO_EVENTS.carousel.next, listener); @@ -65,7 +81,7 @@ describe('Event utility', () => { }); const event = listener.mock.calls[0][0] as CustomEvent; - expect(event.bubbles).toBe(true); + expect(event.bubbles).toBe(false); expect(event.cancelable).toBe(true); window.removeEventListener(CIO_EVENTS.carousel.next, listener); diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index f5df76d..4dd72aa 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -317,8 +317,8 @@ function CarouselNavButton({ dispatchCioEvent(eventName, { direction, - canScrollNext: canScrollNext ?? false, - canScrollPrev: canScrollPrev ?? false, + canScrollNext, + canScrollPrev, }); scrollFn?.(); diff --git a/src/utils/events.ts b/src/utils/events.ts index ff87aa8..353f4dc 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -36,8 +36,14 @@ export interface CioEventDetailMap { } /** - * Dispatches a typed CustomEvent on window for the given CIO event name. - * No-ops in SSR environments where window is undefined. + * Dispatches a typed CustomEvent on `window` for the given CIO event name. + * + * This is the primary pub-sub mechanism for Constructor.io UI component events. + * Consumers subscribe via `window.addEventListener(CIO_EVENTS.*.*, handler)`. + * No-ops in SSR environments where `window` is undefined. + * + * @param eventName - A key from {@link CioEventDetailMap} (use `CIO_EVENTS` constants). + * @param detail - The strongly-typed payload for the event. */ export function dispatchCioEvent( eventName: K, @@ -46,7 +52,7 @@ export function dispatchCioEvent( if (typeof window === 'undefined') return; const event = new CustomEvent(eventName, { - bubbles: true, + bubbles: false, cancelable: true, detail, }); From 033e3092b9450b98d13aafdd94ccc8af27fa48a1 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Mon, 9 Feb 2026 19:25:26 +0400 Subject: [PATCH 04/15] Test no-op --- .../product-card/product-card.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/components/product-card/product-card.test.tsx b/spec/components/product-card/product-card.test.tsx index 139e5b5..d4e1604 100644 --- a/spec/components/product-card/product-card.test.tsx +++ b/spec/components/product-card/product-card.test.tsx @@ -462,6 +462,22 @@ describe('ProductCard component', () => { window.removeEventListener(CIO_EVENTS.productCard.conversion, listener); }); + test('clicking add-to-cart does NOT also dispatch productCard.click', () => { + const clickListener = vi.fn(); + const conversionListener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, clickListener); + window.addEventListener(CIO_EVENTS.productCard.conversion, conversionListener); + + render(); + fireEvent.click(screen.getByText('Add to Cart')); + + expect(conversionListener).toHaveBeenCalledTimes(1); + expect(clickListener).not.toHaveBeenCalled(); + + window.removeEventListener(CIO_EVENTS.productCard.click, clickListener); + window.removeEventListener(CIO_EVENTS.productCard.conversion, conversionListener); + }); + test('dispatches productCard.imageEnter on mouseEnter of image section', () => { const listener = vi.fn(); window.addEventListener(CIO_EVENTS.productCard.imageEnter, listener); From b0259851b8fc64d7760d68e06967595cfc5150f0 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Mon, 9 Feb 2026 19:28:26 +0400 Subject: [PATCH 05/15] Stop propagation --- src/components/product-card.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 2db5b14..31b3ed7 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -241,6 +241,8 @@ const AddToCartButton: React.FC = (props) => { const handleAddToCartClick = useCallback( (e: React.MouseEvent) => { + // Prevent product click from firing + e.stopPropagation(); dispatchCioEvent(CIO_EVENTS.productCard.conversion, { product: renderProps.product }); onAddToCart?.(e); }, From c93fdcc92b2de487fc09d324f32ed033e3cd7a5f Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Thu, 26 Feb 2026 22:26:13 +0300 Subject: [PATCH 06/15] [CDX-376] Emit events on user interaction - Part 2 (#36) * Emit on component rather than window * Lint and types * add comments * Add docs * Fix MDX table issue * Lint * Remove rootRef * Remove refs * Revert export style --- .storybook/main.ts | 12 +- package-lock.json | 1333 +++++++++++++++-- package.json | 1 + spec/components/Carousel/Carousel.test.tsx | 63 +- .../product-card/product-card.test.tsx | 119 +- spec/utils/events.test.ts | 60 +- src/components/card.tsx | 1 - src/components/carousel.tsx | 31 +- src/components/product-card.tsx | 45 +- .../Carousel/CarouselEvents.stories.tsx | 90 ++ .../Carousel/Code Examples - Events.mdx | 79 + .../ProductCard/Code Examples - Events.mdx | 74 + .../ProductCard/ProductCardEvents.stories.tsx | 91 ++ src/utils/events.ts | 14 +- 14 files changed, 1836 insertions(+), 177 deletions(-) create mode 100644 src/stories/components/Carousel/CarouselEvents.stories.tsx create mode 100644 src/stories/components/Carousel/Code Examples - Events.mdx create mode 100644 src/stories/components/ProductCard/Code Examples - Events.mdx create mode 100644 src/stories/components/ProductCard/ProductCardEvents.stories.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts index 152787e..dbab319 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from '@storybook/react-vite'; +import remarkGfm from 'remark-gfm'; const config: StorybookConfig = { "stories": [ @@ -7,7 +8,16 @@ const config: StorybookConfig = { ], "addons": [ "@chromatic-com/storybook", - "@storybook/addon-docs", + { + name: '@storybook/addon-docs', + options: { + mdxPluginOptions: { + mdxCompileOptions: { + remarkPlugins: [remarkGfm], + }, + }, + }, + }, "@storybook/addon-a11y", "@storybook/addon-vitest" ], diff --git a/package-lock.json b/package-lock.json index 4445b01..e239f80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "lint-staged": "^16.1.5", "playwright": "^1.56.1", "prettier": "3.6.2", + "remark-gfm": "^4.0.1", "start-server-and-test": "^2.0.13", "storybook": "^9.1.17", "tsc-alias": "^1.8.16", @@ -4983,6 +4984,16 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -5017,6 +5028,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -5024,6 +5045,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", @@ -5061,6 +5089,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.40.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", @@ -6267,6 +6302,17 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6465,6 +6511,17 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -6499,6 +6556,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -7060,6 +7128,20 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -7152,6 +7234,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -8167,6 +8263,13 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9356,6 +9459,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -10335,6 +10451,17 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -10399,6 +10526,17 @@ "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", "dev": true }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10409,171 +10547,975 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "dev": true, "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, "engines": { - "node": ">=8.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mimic-fn": { + "node_modules/mdast-util-gfm-footnote": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "dev": true, "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dev": true, "license": "MIT", "dependencies": { - "minipass": "^7.1.2" + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" }, - "engines": { - "node": ">= 18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.6" + "@types/mdast": "^4.0.0" }, - "bin": { - "mkdirp": "bin/cmd.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mri": { + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", @@ -11803,6 +12745,58 @@ "node": ">=6" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -13110,6 +14104,17 @@ "node": ">=0.6" } }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -13419,6 +14424,85 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13537,6 +14621,36 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.1.12", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", @@ -14136,6 +15250,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 46a67eb..8642fb5 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "lint-staged": "^16.1.5", "playwright": "^1.56.1", "prettier": "3.6.2", + "remark-gfm": "^4.0.1", "start-server-and-test": "^2.0.13", "storybook": "^9.1.17", "tsc-alias": "^1.8.16", diff --git a/spec/components/Carousel/Carousel.test.tsx b/spec/components/Carousel/Carousel.test.tsx index 5819976..b7bbeb0 100644 --- a/spec/components/Carousel/Carousel.test.tsx +++ b/spec/components/Carousel/Carousel.test.tsx @@ -603,11 +603,13 @@ describe('Carousel component', () => { cleanup(); }); - test('dispatches carousel.next event when next button is clicked', () => { + test('dispatches carousel.next event on root element when next button is clicked', () => { + const { container } = render(); + + const el = container.querySelector('[data-slot="carousel"]')!; const listener = vi.fn(); - window.addEventListener(CIO_EVENTS.carousel.next, listener); + el.addEventListener(CIO_EVENTS.carousel.next, listener); - render(); const nextButton = screen.getByRole('button', { name: /next/i }); fireEvent.click(nextButton); @@ -617,14 +619,16 @@ describe('Carousel component', () => { expect(typeof event.detail.canScrollNext).toBe('boolean'); expect(typeof event.detail.canScrollPrev).toBe('boolean'); - window.removeEventListener(CIO_EVENTS.carousel.next, listener); + el.removeEventListener(CIO_EVENTS.carousel.next, listener); }); - test('dispatches carousel.previous event when previous button is clicked', () => { + test('dispatches carousel.previous event on root element when previous button is clicked', () => { + const { container } = render(); + + const el = container.querySelector('[data-slot="carousel"]')!; const listener = vi.fn(); - window.addEventListener(CIO_EVENTS.carousel.previous, listener); + el.addEventListener(CIO_EVENTS.carousel.previous, listener); - render(); const prevButton = screen.getByRole('button', { name: /previous/i }); fireEvent.click(prevButton); @@ -634,7 +638,20 @@ describe('Carousel component', () => { expect(typeof event.detail.canScrollNext).toBe('boolean'); expect(typeof event.detail.canScrollPrev).toBe('boolean'); - window.removeEventListener(CIO_EVENTS.carousel.previous, listener); + el.removeEventListener(CIO_EVENTS.carousel.previous, listener); + }); + + test('events bubble up so window listeners still work', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.carousel.next, listener); + + render(); + const nextButton = screen.getByRole('button', { name: /next/i }); + fireEvent.click(nextButton); + + expect(listener).toHaveBeenCalledTimes(1); + + window.removeEventListener(CIO_EVENTS.carousel.next, listener); }); test('carousel navigation still works alongside event dispatch', () => { @@ -650,5 +667,35 @@ describe('Carousel component', () => { // Products should still be rendered expect(screen.getByText('Product 1')).toBeInTheDocument(); }); + + test('two carousels: events do not cross-pollinate', () => { + render( + <> +
+ +
+
+ +
+ , + ); + + const wrapper1 = screen.getByTestId('wrapper-1'); + const wrapper2 = screen.getByTestId('wrapper-2'); + const listener1 = vi.fn(); + const listener2 = vi.fn(); + wrapper1.addEventListener(CIO_EVENTS.carousel.next, listener1); + wrapper2.addEventListener(CIO_EVENTS.carousel.next, listener2); + + // Click next on the first carousel only + const nextButtons = screen.getAllByRole('button', { name: /next/i }); + fireEvent.click(nextButtons[0]); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).not.toHaveBeenCalled(); + + wrapper1.removeEventListener(CIO_EVENTS.carousel.next, listener1); + wrapper2.removeEventListener(CIO_EVENTS.carousel.next, listener2); + }); }); }); diff --git a/spec/components/product-card/product-card.test.tsx b/spec/components/product-card/product-card.test.tsx index d4e1604..ea054c3 100644 --- a/spec/components/product-card/product-card.test.tsx +++ b/spec/components/product-card/product-card.test.tsx @@ -400,25 +400,24 @@ describe('ProductCard component', () => { cleanup(); }); - test('dispatches productCard.click event on card click with correct product detail', () => { + test('dispatches productCard.click event on root element with correct product detail', () => { + render(); + + const el = screen.getByTestId('product-card'); const listener = vi.fn(); - window.addEventListener(CIO_EVENTS.productCard.click, listener); + el.addEventListener(CIO_EVENTS.productCard.click, listener); - render(); - fireEvent.click(screen.getByTestId('product-card')); + fireEvent.click(el); expect(listener).toHaveBeenCalledTimes(1); const event = listener.mock.calls[0][0] as CustomEvent; expect(event.detail.product).toEqual(mockProductData.product); - window.removeEventListener(CIO_EVENTS.productCard.click, listener); + el.removeEventListener(CIO_EVENTS.productCard.click, listener); }); test('dispatches productCard.click event AND calls onProductClick callback', () => { - const listener = vi.fn(); const mockOnProductClick = vi.fn(); - window.addEventListener(CIO_EVENTS.productCard.click, listener); - render( { data-testid='product-card' />, ); - fireEvent.click(screen.getByTestId('product-card')); + + const el = screen.getByTestId('product-card'); + const listener = vi.fn(); + el.addEventListener(CIO_EVENTS.productCard.click, listener); + + fireEvent.click(el); expect(listener).toHaveBeenCalledTimes(1); expect(mockOnProductClick).toHaveBeenCalledTimes(1); - window.removeEventListener(CIO_EVENTS.productCard.click, listener); + el.removeEventListener(CIO_EVENTS.productCard.click, listener); }); - test('dispatches productCard.click event even without onProductClick prop', () => { + test('events bubble up so window listeners still work', () => { const listener = vi.fn(); window.addEventListener(CIO_EVENTS.productCard.click, listener); - render(); + render(); fireEvent.click(screen.getByTestId('product-card')); expect(listener).toHaveBeenCalledTimes(1); @@ -446,12 +450,28 @@ describe('ProductCard component', () => { window.removeEventListener(CIO_EVENTS.productCard.click, listener); }); - test('dispatches productCard.conversion event on add-to-cart click and calls onAddToCart', () => { + test('dispatches productCard.click event even without onProductClick prop', () => { + render(); + + const el = screen.getByTestId('product-card'); const listener = vi.fn(); + el.addEventListener(CIO_EVENTS.productCard.click, listener); + + fireEvent.click(el); + + expect(listener).toHaveBeenCalledTimes(1); + + el.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('dispatches productCard.conversion event on root element on add-to-cart click', () => { const mockOnAddToCart = vi.fn(); - window.addEventListener(CIO_EVENTS.productCard.conversion, listener); + render(); + + const el = screen.getByTestId('product-card'); + const listener = vi.fn(); + el.addEventListener(CIO_EVENTS.productCard.conversion, listener); - render(); fireEvent.click(screen.getByText('Add to Cart')); expect(listener).toHaveBeenCalledTimes(1); @@ -459,53 +479,90 @@ describe('ProductCard component', () => { expect(event.detail.product).toEqual(mockProductData.product); expect(mockOnAddToCart).toHaveBeenCalledTimes(1); - window.removeEventListener(CIO_EVENTS.productCard.conversion, listener); + el.removeEventListener(CIO_EVENTS.productCard.conversion, listener); }); test('clicking add-to-cart does NOT also dispatch productCard.click', () => { + render(); + + const el = screen.getByTestId('product-card'); const clickListener = vi.fn(); const conversionListener = vi.fn(); - window.addEventListener(CIO_EVENTS.productCard.click, clickListener); - window.addEventListener(CIO_EVENTS.productCard.conversion, conversionListener); + el.addEventListener(CIO_EVENTS.productCard.click, clickListener); + el.addEventListener(CIO_EVENTS.productCard.conversion, conversionListener); - render(); fireEvent.click(screen.getByText('Add to Cart')); expect(conversionListener).toHaveBeenCalledTimes(1); expect(clickListener).not.toHaveBeenCalled(); - window.removeEventListener(CIO_EVENTS.productCard.click, clickListener); - window.removeEventListener(CIO_EVENTS.productCard.conversion, conversionListener); + el.removeEventListener(CIO_EVENTS.productCard.click, clickListener); + el.removeEventListener(CIO_EVENTS.productCard.conversion, conversionListener); }); - test('dispatches productCard.imageEnter on mouseEnter of image section', () => { + test('dispatches productCard.imageEnter on root element on mouseEnter of image section', () => { + render(); + + const el = screen.getByTestId('product-card'); const listener = vi.fn(); - window.addEventListener(CIO_EVENTS.productCard.imageEnter, listener); + el.addEventListener(CIO_EVENTS.productCard.imageEnter, listener); - const { container } = render(); - const imageSection = container.querySelector('.cio-product-card-image-section')!; + const imageSection = el.querySelector('.cio-product-card-image-section')!; fireEvent.mouseEnter(imageSection); expect(listener).toHaveBeenCalledTimes(1); const event = listener.mock.calls[0][0] as CustomEvent; expect(event.detail.product).toEqual(mockProductData.product); - window.removeEventListener(CIO_EVENTS.productCard.imageEnter, listener); + el.removeEventListener(CIO_EVENTS.productCard.imageEnter, listener); }); - test('dispatches productCard.imageLeave on mouseLeave of image section', () => { + test('dispatches productCard.imageLeave on root element on mouseLeave of image section', () => { + render(); + + const el = screen.getByTestId('product-card'); const listener = vi.fn(); - window.addEventListener(CIO_EVENTS.productCard.imageLeave, listener); + el.addEventListener(CIO_EVENTS.productCard.imageLeave, listener); - const { container } = render(); - const imageSection = container.querySelector('.cio-product-card-image-section')!; + const imageSection = el.querySelector('.cio-product-card-image-section')!; fireEvent.mouseLeave(imageSection); expect(listener).toHaveBeenCalledTimes(1); const event = listener.mock.calls[0][0] as CustomEvent; expect(event.detail.product).toEqual(mockProductData.product); - window.removeEventListener(CIO_EVENTS.productCard.imageLeave, listener); + el.removeEventListener(CIO_EVENTS.productCard.imageLeave, listener); + }); + + test('two product cards: events do not cross-pollinate', () => { + const product2 = { ...mockProductData, product: { ...mockProductData.product, id: 'product-2', name: 'Product 2' } }; + + render( + <> +
+ +
+
+ +
+ , + ); + + const wrapper1 = screen.getByTestId('wrapper-1'); + const wrapper2 = screen.getByTestId('wrapper-2'); + const listener1 = vi.fn(); + const listener2 = vi.fn(); + wrapper1.addEventListener(CIO_EVENTS.productCard.click, listener1); + wrapper2.addEventListener(CIO_EVENTS.productCard.click, listener2); + + // Click only the first card + fireEvent.click(screen.getByTestId('card-1')); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).not.toHaveBeenCalled(); + + wrapper1.removeEventListener(CIO_EVENTS.productCard.click, listener1); + wrapper2.removeEventListener(CIO_EVENTS.productCard.click, listener2); }); }); }); diff --git a/spec/utils/events.test.ts b/spec/utils/events.test.ts index 213d353..4fb011d 100644 --- a/spec/utils/events.test.ts +++ b/spec/utils/events.test.ts @@ -59,7 +59,6 @@ describe('Event utility', () => { test('no-ops without throwing when window is undefined (SSR)', () => { const originalWindow = globalThis.window; - // @ts-expect-error -- simulating SSR by removing window delete (globalThis as Record).window; try { expect(() => { @@ -70,7 +69,7 @@ describe('Event utility', () => { } }); - test('dispatches with bubbles false and cancelable true', () => { + test('dispatches with bubbles true and cancelable true', () => { const listener = vi.fn(); window.addEventListener(CIO_EVENTS.carousel.next, listener); @@ -81,10 +80,65 @@ describe('Event utility', () => { }); const event = listener.mock.calls[0][0] as CustomEvent; - expect(event.bubbles).toBe(false); + expect(event.bubbles).toBe(true); expect(event.cancelable).toBe(true); window.removeEventListener(CIO_EVENTS.carousel.next, listener); }); + + test('dispatches on a specific DOM element when target is provided', () => { + const element = document.createElement('div'); + document.body.appendChild(element); + const listener = vi.fn(); + element.addEventListener(CIO_EVENTS.productCard.click, listener); + + dispatchCioEvent(CIO_EVENTS.productCard.click, { product: mockProduct }, element); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.product).toEqual(mockProduct); + + element.removeEventListener(CIO_EVENTS.productCard.click, listener); + document.body.removeChild(element); + }); + + test('event bubbles from child element to parent listener', () => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + parent.appendChild(child); + document.body.appendChild(parent); + + const parentListener = vi.fn(); + parent.addEventListener(CIO_EVENTS.productCard.click, parentListener); + + dispatchCioEvent(CIO_EVENTS.productCard.click, { product: mockProduct }, child); + + expect(parentListener).toHaveBeenCalledTimes(1); + + parent.removeEventListener(CIO_EVENTS.productCard.click, parentListener); + document.body.removeChild(parent); + }); + + test('falls back to window when target is null', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + dispatchCioEvent(CIO_EVENTS.productCard.click, { product: mockProduct }, null); + + expect(listener).toHaveBeenCalledTimes(1); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); + + test('falls back to window when target is undefined', () => { + const listener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, listener); + + dispatchCioEvent(CIO_EVENTS.productCard.click, { product: mockProduct }, undefined); + + expect(listener).toHaveBeenCalledTimes(1); + + window.removeEventListener(CIO_EVENTS.productCard.click, listener); + }); }); }); diff --git a/src/components/card.tsx b/src/components/card.tsx index c37e358..59a5691 100644 --- a/src/components/card.tsx +++ b/src/components/card.tsx @@ -64,7 +64,6 @@ const useCardContext = (): CardContextType => { return context; }; -// Helper function to create the Card root function Card({ children, componentOverrides, className, ...props }: CardProps) { const contextValue: CardContextType = React.useMemo( () => ({ diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index 4dd72aa..03674f0 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -312,17 +312,24 @@ function CarouselNavButton({ const canScroll = isPrevious ? canScrollPrev : canScrollNext; const scrollFn = isPrevious ? scrollPrev : scrollNext; - const handleClick = useCallback(() => { - const eventName = isPrevious ? CIO_EVENTS.carousel.previous : CIO_EVENTS.carousel.next; - - dispatchCioEvent(eventName, { - direction, - canScrollNext, - canScrollPrev, - }); - - scrollFn?.(); - }, [isPrevious, direction, canScrollNext, canScrollPrev, scrollFn]); + const handleClick = useCallback( + (e: React.MouseEvent) => { + const eventName = isPrevious ? CIO_EVENTS.carousel.previous : CIO_EVENTS.carousel.next; + + dispatchCioEvent( + eventName, + { + direction, + canScrollNext: canScrollNext ?? false, + canScrollPrev: canScrollPrev ?? false, + }, + e.currentTarget, + ); + + scrollFn?.(); + }, + [isPrevious, direction, canScrollNext, canScrollPrev, scrollFn], + ); const override = isPrevious ? componentOverrides?.previous?.reactNode @@ -360,7 +367,7 @@ function CarouselNext(props: NavButtonProps) { return ; } -// Create compound component with all sub-components attached +// Attach compound components to Carousel Carousel.Content = CarouselContent; Carousel.Item = CarouselItem; Carousel.Previous = CarouselPrevious; diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 31b3ed7..ea6c037 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -160,13 +160,27 @@ const ImageSection: React.FC = (props) => { // Use props with fallback to context values const imageUrl = props.imageUrl || contextImageUrl; - const handleMouseEnter = useCallback(() => { - dispatchCioEvent(CIO_EVENTS.productCard.imageEnter, { product: renderProps.product }); - }, [renderProps.product]); + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + dispatchCioEvent( + CIO_EVENTS.productCard.imageEnter, + { product: renderProps.product }, + e.currentTarget, + ); + }, + [renderProps.product], + ); - const handleMouseLeave = useCallback(() => { - dispatchCioEvent(CIO_EVENTS.productCard.imageLeave, { product: renderProps.product }); - }, [renderProps.product]); + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + dispatchCioEvent( + CIO_EVENTS.productCard.imageLeave, + { product: renderProps.product }, + e.currentTarget, + ); + }, + [renderProps.product], + ); return ( @@ -243,7 +257,11 @@ const AddToCartButton: React.FC = (props) => { (e: React.MouseEvent) => { // Prevent product click from firing e.stopPropagation(); - dispatchCioEvent(CIO_EVENTS.productCard.conversion, { product: renderProps.product }); + dispatchCioEvent( + CIO_EVENTS.productCard.conversion, + { product: renderProps.product }, + e.currentTarget, + ); onAddToCart?.(e); }, [renderProps.product, onAddToCart], @@ -371,10 +389,13 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod ...restProps } = props; - const handleProductClick = useCallback(() => { - dispatchCioEvent(CIO_EVENTS.productCard.click, { product }); - onProductClick?.(); - }, [product, onProductClick]); + const handleProductClick = useCallback( + (e: React.MouseEvent) => { + dispatchCioEvent(CIO_EVENTS.productCard.click, { product }, e.currentTarget); + onProductClick?.(); + }, + [product, onProductClick], + ); const renderPropFn = typeof children === 'function' && children; @@ -419,7 +440,7 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod ); } -// Create compound component with all sub-components attached +// Attach compound components to ProductCard ProductCard.ImageSection = ImageSection; ProductCard.Badge = Badge; ProductCard.WishlistButton = WishlistButton; diff --git a/src/stories/components/Carousel/CarouselEvents.stories.tsx b/src/stories/components/Carousel/CarouselEvents.stories.tsx new file mode 100644 index 0000000..6f0a92a --- /dev/null +++ b/src/stories/components/Carousel/CarouselEvents.stories.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import CioCarousel from '../../../components/carousel'; +import { Product } from '../../../types/productCardTypes'; +import { CIO_EVENTS } from '../../../utils/events'; +import { DEMO_IMAGE_URL } from '../../constants'; + +const meta = { + title: 'Components/Carousel', + component: CioCarousel, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockProducts: Product[] = Array.from({ length: 10 }, (_, i) => ({ + id: `product-${i + 1}`, + name: `Product ${i + 1}`, + description: `This is a description for product ${i + 1}`, + imageUrl: DEMO_IMAGE_URL, + price: (Math.random() * 100 + 20).toFixed(2), + salePrice: Math.random() > 0.5 ? (Math.random() * 80 + 10).toFixed(2) : undefined, + rating: (Math.random() * 2 + 3).toFixed(1), + reviewsCount: Math.floor(Math.random() * 500 + 10), + tags: ['Tag 1', 'Tag 2'].slice(0, Math.floor(Math.random() * 3)), +})); + +function CarouselEventListeningDemo() { + const wrapperRef = useRef(null); + const [eventLog, setEventLog] = useState([]); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + + const logEvent = (label: string) => (e: Event) => { + const detail = (e as CustomEvent).detail; + setEventLog((prev) => [ + `[${new Date().toLocaleTimeString()}] ${label} — direction: ${detail?.direction}, canScrollNext: ${detail?.canScrollNext}, canScrollPrev: ${detail?.canScrollPrev}`, + ...prev.slice(0, 49), + ]); + }; + + const handlers = [ + [CIO_EVENTS.carousel.next, logEvent('carousel.next')] as const, + [CIO_EVENTS.carousel.previous, logEvent('carousel.previous')] as const, + ]; + + handlers.forEach(([name, fn]) => el.addEventListener(name, fn)); + return () => handlers.forEach(([name, fn]) => el.removeEventListener(name, fn)); + }, []); + + return ( +
+
+ +
+
+
Event Log
+ {eventLog.length === 0 ? ( +
Click the carousel arrows to see events...
+ ) : ( + eventLog.map((entry, i) => ( +
+ {entry} +
+ )) + )} +
+
+ ); +} + +export const EventListening: Story = { + render: () => , + tags: ['!autodocs', '!dev'], +}; diff --git a/src/stories/components/Carousel/Code Examples - Events.mdx b/src/stories/components/Carousel/Code Examples - Events.mdx new file mode 100644 index 0000000..084af6b --- /dev/null +++ b/src/stories/components/Carousel/Code Examples - Events.mdx @@ -0,0 +1,79 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; +import * as CarouselEventsStories from './CarouselEvents.stories'; + + + +# `Carousel` - Scoped Event Listening + +Listen for navigation events on a `Carousel` instance by wrapping it in a container element, instead of listening on `window`. + +## Why scoped events? + +When multiple `Carousel` instances exist on the same page, listening on `window` means **every** listener fires for **every** carousel. Scoped events solve this: each carousel dispatches events on its own root DOM element with `bubbles: true`, so a listener attached to any ancestor element only receives events from carousels within that subtree. + +## Interactive Demo + +Click the previous/next arrows to see navigation events appear in the log panel below. + + + +## Basic Setup + +```tsx +import { useRef, useEffect } from 'react'; +import CioCarousel from '@constructorio/ui-components/carousel'; +import { CIO_EVENTS } from '@constructorio/ui-components'; + +function MyCarousel({ products }) { + const wrapperRef = useRef(null); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + + const handleNav = (e: Event) => { + const { direction, canScrollNext, canScrollPrev } = (e as CustomEvent).detail; + console.log(`Scrolled ${direction}`, { canScrollNext, canScrollPrev }); + }; + + el.addEventListener(CIO_EVENTS.carousel.next, handleNav); + el.addEventListener(CIO_EVENTS.carousel.previous, handleNav); + + return () => { + el.removeEventListener(CIO_EVENTS.carousel.next, handleNav); + el.removeEventListener(CIO_EVENTS.carousel.previous, handleNav); + }; + }, []); + + return ( +
+ +
+ ); +} +``` + +## Available Events + +Both events carry a `CarouselNavEventDetail` payload with the scroll direction and current scroll state. + +| Event Name | Constant | Fires When | +| ------------------------------------- | --------------------------------- | ---------------------- | +| `cio.components.carousel.next` | `CIO_EVENTS.carousel.next` | Next button clicked | +| `cio.components.carousel.previous` | `CIO_EVENTS.carousel.previous` | Previous button clicked| + +**Payload type:** `CarouselNavEventDetail` + +```ts +interface CarouselNavEventDetail { + direction: 'next' | 'previous'; + canScrollNext: boolean; + canScrollPrev: boolean; +} +``` + +## Notes + +- All events are dispatched with `bubbles: true`, so you can listen on any ancestor element instead of the carousel itself. +- The `detail` payload includes the current scroll state (`canScrollNext`, `canScrollPrev`) so you can update UI accordingly. +- Use `CIO_EVENTS` constants instead of raw strings to avoid typos and get autocomplete. diff --git a/src/stories/components/ProductCard/Code Examples - Events.mdx b/src/stories/components/ProductCard/Code Examples - Events.mdx new file mode 100644 index 0000000..3a04c45 --- /dev/null +++ b/src/stories/components/ProductCard/Code Examples - Events.mdx @@ -0,0 +1,74 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; +import * as ProductCardEventsStories from './ProductCardEvents.stories'; + + + +# `ProductCard` - Scoped Event Listening + +Listen for user-interaction events on a `ProductCard` instance by wrapping it in a container element, instead of listening on `window`. + +## Why scoped events? + +When multiple `ProductCard` instances exist on the same page, listening on `window` means **every** listener fires for **every** card. Scoped events solve this: each card dispatches events on its own root DOM element with `bubbles: true`, so a listener attached to any ancestor element only receives events from cards within that subtree. + +## Interactive Demo + +Click the card, hover the image, or click "Add to Cart" to see events appear in the log panel below. + + + +## Basic Setup + +```tsx +import { useRef, useEffect } from 'react'; +import ProductCard from '@constructorio/ui-components/product-card'; +import { CIO_EVENTS } from '@constructorio/ui-components'; + +function MyProductCard({ product }) { + const wrapperRef = useRef(null); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + + const handleClick = (e: Event) => { + const { product } = (e as CustomEvent).detail; + console.log('Card clicked:', product.name); + }; + + el.addEventListener(CIO_EVENTS.productCard.click, handleClick); + return () => el.removeEventListener(CIO_EVENTS.productCard.click, handleClick); + }, []); + + return ( +
+ +
+ ); +} +``` + +## Available Events + +All events carry a `ProductCardEventDetail` payload containing the `product: Product` for the card that fired. + +| Event Name | Constant | Fires When | +| --------------------------------------------- | ------------------------------------- | ------------------------- | +| `cio.components.productCard.click` | `CIO_EVENTS.productCard.click` | Card is clicked | +| `cio.components.productCard.conversion` | `CIO_EVENTS.productCard.conversion` | Add to Cart is clicked | +| `cio.components.productCard.imageEnter` | `CIO_EVENTS.productCard.imageEnter` | Mouse enters card image | +| `cio.components.productCard.imageLeave` | `CIO_EVENTS.productCard.imageLeave` | Mouse leaves card image | + +**Payload type:** `ProductCardEventDetail` + +```ts +interface ProductCardEventDetail { + product: Product; +} +``` + +## Notes + +- All events are dispatched with `bubbles: true`, so you can listen on any ancestor element instead of the card itself. +- The `detail` payload always contains the `Product` object for the card that fired the event. +- Use `CIO_EVENTS` constants instead of raw strings to avoid typos and get autocomplete. diff --git a/src/stories/components/ProductCard/ProductCardEvents.stories.tsx b/src/stories/components/ProductCard/ProductCardEvents.stories.tsx new file mode 100644 index 0000000..12f1cf0 --- /dev/null +++ b/src/stories/components/ProductCard/ProductCardEvents.stories.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import ProductCard from '../../../components/product-card'; +import { CIO_EVENTS } from '../../../utils/events'; +import { DEMO_IMAGE_URL } from '../../constants'; + +const meta = { + title: 'Components/ProductCard', + component: ProductCard, + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function ProductCardEventListeningDemo() { + const wrapperRef = useRef(null); + const [eventLog, setEventLog] = useState([]); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + + const logEvent = (label: string) => (e: Event) => { + const detail = (e as CustomEvent).detail; + setEventLog((prev) => [ + `[${new Date().toLocaleTimeString()}] ${label} — product: ${detail?.product?.name ?? 'N/A'}`, + ...prev.slice(0, 49), + ]); + }; + + const handlers = [ + [CIO_EVENTS.productCard.click, logEvent('productCard.click')] as const, + [CIO_EVENTS.productCard.conversion, logEvent('productCard.conversion')] as const, + [CIO_EVENTS.productCard.imageEnter, logEvent('productCard.imageEnter')] as const, + [CIO_EVENTS.productCard.imageLeave, logEvent('productCard.imageLeave')] as const, + ]; + + handlers.forEach(([name, fn]) => el.addEventListener(name, fn)); + return () => handlers.forEach(([name, fn]) => el.removeEventListener(name, fn)); + }, []); + + return ( +
+
+ {}} + /> +
+
+
Event Log
+ {eventLog.length === 0 ? ( +
Interact with the card above to see events...
+ ) : ( + eventLog.map((entry, i) => ( +
+ {entry} +
+ )) + )} +
+
+ ); +} + +export const EventListening: Story = { + render: () => , + tags: ['!autodocs', '!dev'], +}; diff --git a/src/utils/events.ts b/src/utils/events.ts index 353f4dc..b423efd 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -36,25 +36,29 @@ export interface CioEventDetailMap { } /** - * Dispatches a typed CustomEvent on `window` for the given CIO event name. + * Dispatches a typed CustomEvent for the given CIO event name. * * This is the primary pub-sub mechanism for Constructor.io UI component events. - * Consumers subscribe via `window.addEventListener(CIO_EVENTS.*.*, handler)`. - * No-ops in SSR environments where `window` is undefined. + * When a `target` element is provided, the event is dispatched on that element + * and bubbles up the DOM tree. Consumers can listen on the component element or + * any ancestor. When no target is provided (or during SSR), falls back to + * dispatching on `window` for backwards compatibility. * * @param eventName - A key from {@link CioEventDetailMap} (use `CIO_EVENTS` constants). * @param detail - The strongly-typed payload for the event. + * @param target - Optional DOM element to dispatch the event on. Falls back to `window`. */ export function dispatchCioEvent( eventName: K, detail: CioEventDetailMap[K], + target?: EventTarget | null, ): void { if (typeof window === 'undefined') return; const event = new CustomEvent(eventName, { - bubbles: false, + bubbles: true, // lets consumers listen on any ancestor, not just the dispatching element. cancelable: true, detail, }); - window.dispatchEvent(event); + (target || window).dispatchEvent(event); } From efbc7315f5c2e3b64c6111f2f2657127e8efb1e2 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Thu, 26 Feb 2026 23:25:26 +0300 Subject: [PATCH 07/15] Remove stopPropagation --- .../product-card/product-card.test.tsx | 34 +++++++++++++++++++ src/components/product-card.tsx | 12 +++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/spec/components/product-card/product-card.test.tsx b/spec/components/product-card/product-card.test.tsx index ea054c3..c3c59a0 100644 --- a/spec/components/product-card/product-card.test.tsx +++ b/spec/components/product-card/product-card.test.tsx @@ -500,6 +500,40 @@ describe('ProductCard component', () => { el.removeEventListener(CIO_EVENTS.productCard.conversion, conversionListener); }); + test('clicking add-to-cart does NOT call onProductClick callback', () => { + const mockOnProductClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Add to Cart')); + expect(mockOnProductClick).not.toHaveBeenCalled(); + }); + + test('clicking wishlist button does NOT call onProductClick callback', () => { + const mockOnProductClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /add to wishlist/i })); + expect(mockOnProductClick).not.toHaveBeenCalled(); + }); + + test('clicking wishlist button does NOT dispatch productCard.click event', () => { + const clickListener = vi.fn(); + window.addEventListener(CIO_EVENTS.productCard.click, clickListener); + + render(); + fireEvent.click(screen.getByRole('button', { name: /add to wishlist/i })); + + expect(clickListener).not.toHaveBeenCalled(); + + window.removeEventListener(CIO_EVENTS.productCard.click, clickListener); + }); + test('dispatches productCard.imageEnter on root element on mouseEnter of image section', () => { render(); diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index ea6c037..516db2a 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -255,8 +255,6 @@ const AddToCartButton: React.FC = (props) => { const handleAddToCartClick = useCallback( (e: React.MouseEvent) => { - // Prevent product click from firing - e.stopPropagation(); dispatchCioEvent( CIO_EVENTS.productCard.conversion, { product: renderProps.product }, @@ -391,6 +389,16 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod const handleProductClick = useCallback( (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + + // Do not fire if a children button is clicked + if ( + target.closest('.cio-product-card-add-to-cart-btn') || + target.closest('.cio-product-card-wishlist-btn') + ) { + return; + } + dispatchCioEvent(CIO_EVENTS.productCard.click, { product }, e.currentTarget); onProductClick?.(); }, From 4ba69a1dbdb0e2b315b44faf5221ab9ea86943ab Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Thu, 26 Feb 2026 23:32:13 +0300 Subject: [PATCH 08/15] remove unnecessary test --- spec/utils/events.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/spec/utils/events.test.ts b/spec/utils/events.test.ts index 4fb011d..3959c8e 100644 --- a/spec/utils/events.test.ts +++ b/spec/utils/events.test.ts @@ -69,23 +69,6 @@ describe('Event utility', () => { } }); - test('dispatches with bubbles true and cancelable true', () => { - const listener = vi.fn(); - window.addEventListener(CIO_EVENTS.carousel.next, listener); - - dispatchCioEvent(CIO_EVENTS.carousel.next, { - direction: 'next', - canScrollNext: true, - canScrollPrev: false, - }); - - const event = listener.mock.calls[0][0] as CustomEvent; - expect(event.bubbles).toBe(true); - expect(event.cancelable).toBe(true); - - window.removeEventListener(CIO_EVENTS.carousel.next, listener); - }); - test('dispatches on a specific DOM element when target is provided', () => { const element = document.createElement('div'); document.body.appendChild(element); From a403ac4bbf8d210ad3e26d33952d23bddcba6429 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Thu, 26 Feb 2026 23:39:38 +0300 Subject: [PATCH 09/15] Bring back comment --- src/components/card.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/card.tsx b/src/components/card.tsx index 59a5691..c37e358 100644 --- a/src/components/card.tsx +++ b/src/components/card.tsx @@ -64,6 +64,7 @@ const useCardContext = (): CardContextType => { return context; }; +// Helper function to create the Card root function Card({ children, componentOverrides, className, ...props }: CardProps) { const contextValue: CardContextType = React.useMemo( () => ({ From 7c86966fed7f028fe6663c100a1e7fbc03db6320 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Thu, 26 Feb 2026 23:41:25 +0300 Subject: [PATCH 10/15] Add product to callbacks --- .../product-card/product-card.test.tsx | 7 +- src/components/product-card.tsx | 6 +- .../Code Examples - Component Overrides.mdx | 4 +- .../Code Examples - Render Props.mdx | 4 +- .../ProductCard/ProductCard.stories.tsx | 113 +++++++++--------- .../ProductCard/ProductCardVariants.tsx | 6 +- src/types/productCardTypes.ts | 10 +- 7 files changed, 77 insertions(+), 73 deletions(-) diff --git a/spec/components/product-card/product-card.test.tsx b/spec/components/product-card/product-card.test.tsx index c3c59a0..fda8250 100644 --- a/spec/components/product-card/product-card.test.tsx +++ b/spec/components/product-card/product-card.test.tsx @@ -82,7 +82,7 @@ describe('ProductCard component', () => { render(); fireEvent.click(screen.getByText('Add to Cart')); expect(mockOnAddToCart).toHaveBeenCalledTimes(1); - expect(mockOnAddToCart).toHaveBeenCalledWith(expect.any(Object)); + expect(mockOnAddToCart).toHaveBeenCalledWith(expect.any(Object), mockProductData.product); }); test('calls onAddToWishlist when wishlist button is clicked', () => { @@ -90,7 +90,7 @@ describe('ProductCard component', () => { render(); fireEvent.click(screen.getByRole('button', { name: /add to wishlist/i })); expect(mockOnAddToWishlist).toHaveBeenCalledTimes(1); - expect(mockOnAddToWishlist).toHaveBeenCalledWith(expect.any(Object)); + expect(mockOnAddToWishlist).toHaveBeenCalledWith(expect.any(Object), mockProductData.product); }); test('calls onProductClick when product card is clicked', () => { @@ -104,6 +104,7 @@ describe('ProductCard component', () => { ); fireEvent.click(screen.getByTestId('product-card')); expect(mockOnProductClick).toHaveBeenCalledTimes(1); + expect(mockOnProductClick).toHaveBeenCalledWith(mockProductData.product); }); test('does not render add to cart button when onAddToCart is not provided', () => { @@ -434,6 +435,7 @@ describe('ProductCard component', () => { expect(listener).toHaveBeenCalledTimes(1); expect(mockOnProductClick).toHaveBeenCalledTimes(1); + expect(mockOnProductClick).toHaveBeenCalledWith(mockProductData.product); el.removeEventListener(CIO_EVENTS.productCard.click, listener); }); @@ -478,6 +480,7 @@ describe('ProductCard component', () => { const event = listener.mock.calls[0][0] as CustomEvent; expect(event.detail.product).toEqual(mockProductData.product); expect(mockOnAddToCart).toHaveBeenCalledTimes(1); + expect(mockOnAddToCart).toHaveBeenCalledWith(expect.any(Object), mockProductData.product); el.removeEventListener(CIO_EVENTS.productCard.conversion, listener); }); diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 516db2a..7433015 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -58,7 +58,7 @@ const WishlistButton: React.FC = (props) => { size='icon' variant='secondary' conversionType='add_to_wishlist' - onClick={onAddToWishlist} + onClick={(e) => onAddToWishlist?.(e, renderProps.product)} aria-label={isInWishlist ? 'Remove from wishlist' : 'Add to wishlist'}> = (props) => { { product: renderProps.product }, e.currentTarget, ); - onAddToCart?.(e); + onAddToCart?.(e, renderProps.product); }, [renderProps.product, onAddToCart], ); @@ -400,7 +400,7 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod } dispatchCioEvent(CIO_EVENTS.productCard.click, { product }, e.currentTarget); - onProductClick?.(); + onProductClick?.(product); }, [product, onProductClick], ); diff --git a/src/stories/components/ProductCard/Code Examples - Component Overrides.mdx b/src/stories/components/ProductCard/Code Examples - Component Overrides.mdx index ef3f17a..4a814ff 100644 --- a/src/stories/components/ProductCard/Code Examples - Component Overrides.mdx +++ b/src/stories/components/ProductCard/Code Examples - Component Overrides.mdx @@ -101,7 +101,7 @@ const addToCartButtonOverride = { reactNode: (props) => ( @@ -144,7 +144,7 @@ const wishlistButtonOverride = { reactNode: (props) => ( diff --git a/src/stories/components/ProductCard/Code Examples - Render Props.mdx b/src/stories/components/ProductCard/Code Examples - Render Props.mdx index 36fc7f9..59cfd3c 100644 --- a/src/stories/components/ProductCard/Code Examples - Render Props.mdx +++ b/src/stories/components/ProductCard/Code Examples - Render Props.mdx @@ -36,7 +36,7 @@ function CustomOverrideExample() {

{props.priceCurrency}{props.product.price}

-
@@ -90,7 +90,7 @@ function CompactListStyleCard(props) {
diff --git a/src/stories/components/ProductCard/ProductCard.stories.tsx b/src/stories/components/ProductCard/ProductCard.stories.tsx index 5702fa3..0cff32e 100644 --- a/src/stories/components/ProductCard/ProductCard.stories.tsx +++ b/src/stories/components/ProductCard/ProductCard.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import ProductCard from '../../../components/product-card'; import { CompleteCustomOverrideCard, CompactListStyleCard } from './ProductCardVariants'; -import { ProductCardProps } from '../../../types/productCardTypes'; +import { Product, ProductCardProps } from '../../../types/productCardTypes'; import { DEMO_IMAGE_URL } from '../../constants'; const meta = { @@ -114,7 +114,7 @@ export const WithAddToCart: Story = { imageUrl: DEMO_IMAGE_URL, price: '299', }, - onAddToCart: (e) => console.log('Added to cart', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), }, argTypes: { onAddToCart: { action: 'add to cart clicked' }, @@ -131,7 +131,7 @@ export const WithWishlist: Story = { imageUrl: DEMO_IMAGE_URL, price: '299', }, - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), }, argTypes: { onAddToWishlist: { action: 'add to wishlist clicked' }, @@ -162,7 +162,7 @@ export const CustomAddToCartText: Story = { imageUrl: DEMO_IMAGE_URL, price: '299', }, - onAddToCart: (e) => console.log('Added to cart', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), addToCartText: 'Buy Now', }, argTypes: { @@ -184,8 +184,8 @@ export const CustomCurrency: Story = { reviewsCount: 89, }, priceCurrency: '€', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), }, argTypes: { onAddToCart: { action: 'add to cart clicked' }, @@ -211,8 +211,8 @@ export const FullyFeatured: Story = { badge: 'New', }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), onProductClick: () => console.log('Product clicked'), addToCartText: 'Add to Cart', }, @@ -238,8 +238,8 @@ export const InWishlist: Story = { }, priceCurrency: '$', isInWishlist: true, - onAddToWishlist: (e) => console.log('Removed from wishlist', e), - onAddToCart: (e) => console.log('Added to cart', e), + onAddToWishlist: (e, product) => console.log('Removed from wishlist', e, product), + onAddToCart: (e, product) => console.log('Added to cart', e, product), }, argTypes: { onAddToCart: { action: 'add to cart clicked' }, @@ -278,8 +278,8 @@ export const CustomBadge: Story = { reviewsCount: 2713, }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), componentOverrides: { image: { badge: { @@ -316,8 +316,8 @@ export const CustomBadgeCompound: Story = { reviewsCount: 2713, }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), }, render: (args) => ( @@ -359,8 +359,8 @@ export const CompoundBasic: Story = { price: '299', }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), }, render: (args) => ( @@ -407,7 +407,7 @@ export const CompoundFullyFeatured: Story = { console.log('Added to wishlist', e)} + onAddToWishlist={(e: React.MouseEvent, product: Product) => console.log('Added to wishlist', e, product)} /> New @@ -419,7 +419,7 @@ export const CompoundFullyFeatured: Story = { console.log('Added to cart', e)} + onAddToCart={(e: React.MouseEvent, product: Product) => console.log('Added to cart', e, product)} /> @@ -450,8 +450,8 @@ export const CompoundCustomLayout: Story = { tags: ['Premium', 'Fast Shipping'], }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), }, render: (args) => ( @@ -494,7 +494,7 @@ export const CompoundGridLayout: Story = { description: 'Premium golf pants designed for comfort and performance on the course', }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), className: 'overflow-hidden max-w-md', }, render: (args) => ( @@ -530,7 +530,7 @@ export const CompoundMinimal: Story = { price: '199', }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), }, render: (args) => ( @@ -570,8 +570,8 @@ export const CompleteCustomOverride: Story = { tags: ['Premium', 'Limited Edition'], }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), children: (props: ProductCardProps) => , }, argTypes: { @@ -594,7 +594,7 @@ export const CompactListStyle: Story = { reviewsCount: 156, }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), children: (props: ProductCardProps) => , }, argTypes: { @@ -651,11 +651,11 @@ const titleOverride = { }; const addToCartButtonOverride = { - reactNode: (props: { onAddToCart?: (e: React.MouseEvent) => void; addToCartText?: string }) => ( + reactNode: (props: { onAddToCart?: (e: React.MouseEvent, product: Product) => void; addToCartText?: string; product: Product }) => ( ), @@ -666,7 +666,7 @@ const wishlistButtonOverride = { ), @@ -681,7 +681,7 @@ const footerOverride = { )} @@ -1097,19 +1097,20 @@ export const ComponentOverrideExample: Story = { tags: ['Same day delivery', 'Free assembly'], }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), addToCartText: 'Add to Cart', componentOverrides: { footer: { addToCartButton: { reactNode: (props: { - onAddToCart?: (e: React.MouseEvent) => void; + onAddToCart?: (e: React.MouseEvent, product: Product) => void; addToCartText?: string; + product: Product; }) => ( ), @@ -1139,8 +1140,8 @@ export const DataAttributesExample: Story = { reviewsCount: 2713, }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), addToCartText: 'Add to Cart', // @ts-expect-error: Data Attribute 'data-cnstrc-item-id': 'product-123', @@ -1199,7 +1200,7 @@ export const RenderPropsExample: Story = { {renderProps.onAddToCart && ( )} @@ -1220,8 +1221,8 @@ export const RenderPropsExample: Story = { reviewsCount: 2713, }, priceCurrency: '$', - onAddToCart: (e) => console.log('Added to cart', e), - onAddToWishlist: (e) => console.log('Added to wishlist', e), + onAddToCart: (e, product) => console.log('Added to cart', e, product), + onAddToWishlist: (e, product) => console.log('Added to wishlist', e, product), addToCartText: 'Add to Cart', }, argTypes: { diff --git a/src/stories/components/ProductCard/ProductCardVariants.tsx b/src/stories/components/ProductCard/ProductCardVariants.tsx index a8ee8b3..6b97f03 100644 --- a/src/stories/components/ProductCard/ProductCardVariants.tsx +++ b/src/stories/components/ProductCard/ProductCardVariants.tsx @@ -49,12 +49,12 @@ export const CompleteCustomOverrideCard: React.FC = (props) =>
@@ -91,7 +91,7 @@ export const CompactListStyleCard: React.FC = (props) => (
diff --git a/src/types/productCardTypes.ts b/src/types/productCardTypes.ts index d0ae77a..a8d030d 100644 --- a/src/types/productCardTypes.ts +++ b/src/types/productCardTypes.ts @@ -22,9 +22,9 @@ export interface ProductCardProps extends Omit, 'chi priceCurrency?: string; addToCartText?: string; isInWishlist?: boolean; - onAddToCart?: (e: React.MouseEvent) => void; - onAddToWishlist?: (e: React.MouseEvent) => void; - onProductClick?: () => void; + onAddToCart?: (e: React.MouseEvent, product: Product) => void; + onAddToWishlist?: (e: React.MouseEvent, product: Product) => void; + onProductClick?: (product: Product) => void; children?: RenderPropsChildren; componentOverrides?: ProductCardOverrides; } @@ -48,7 +48,7 @@ export type ProductCardOverrides = ComponentOverrideProps & { // Section component interfaces export interface WishlistButtonProps extends IncludeRenderProps { - onAddToWishlist?: (e: React.MouseEvent) => void; + onAddToWishlist?: (e: React.MouseEvent, product: Product) => void; isInWishlist?: boolean; className?: string; } @@ -89,7 +89,7 @@ export interface DescriptionSectionProps extends IncludeRenderProps { - onAddToCart?: (e: React.MouseEvent) => void; + onAddToCart?: (e: React.MouseEvent, product: Product) => void; addToCartText?: string; className?: string; } From 378405f5bafb91d6a1e67650c48c3564e8fdd0a2 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Thu, 26 Feb 2026 23:43:31 +0300 Subject: [PATCH 11/15] lint --- .../ProductCard/ProductCard.stories.tsx | 22 ++++++++++++---- .../ProductCard/ProductCardEvents.stories.tsx | 26 +++++++++---------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/stories/components/ProductCard/ProductCard.stories.tsx b/src/stories/components/ProductCard/ProductCard.stories.tsx index 0cff32e..f65c24b 100644 --- a/src/stories/components/ProductCard/ProductCard.stories.tsx +++ b/src/stories/components/ProductCard/ProductCard.stories.tsx @@ -407,7 +407,9 @@ export const CompoundFullyFeatured: Story = { console.log('Added to wishlist', e, product)} + onAddToWishlist={(e: React.MouseEvent, product: Product) => + console.log('Added to wishlist', e, product) + } /> New @@ -419,7 +421,9 @@ export const CompoundFullyFeatured: Story = { console.log('Added to cart', e, product)} + onAddToCart={(e: React.MouseEvent, product: Product) => + console.log('Added to cart', e, product) + } /> @@ -651,7 +655,11 @@ const titleOverride = { }; const addToCartButtonOverride = { - reactNode: (props: { onAddToCart?: (e: React.MouseEvent, product: Product) => void; addToCartText?: string; product: Product }) => ( + reactNode: (props: { + onAddToCart?: (e: React.MouseEvent, product: Product) => void; + addToCartText?: string; + product: Product; + }) => ( )} @@ -1200,7 +1210,9 @@ export const RenderPropsExample: Story = { {renderProps.onAddToCart && ( )} diff --git a/src/stories/components/ProductCard/ProductCardEvents.stories.tsx b/src/stories/components/ProductCard/ProductCardEvents.stories.tsx index 12f1cf0..128328d 100644 --- a/src/stories/components/ProductCard/ProductCardEvents.stories.tsx +++ b/src/stories/components/ProductCard/ProductCardEvents.stories.tsx @@ -45,19 +45,19 @@ function ProductCardEventListeningDemo() { return (
- {}} - /> + {}} + />
Date: Fri, 27 Feb 2026 17:12:45 +0300 Subject: [PATCH 12/15] Simplify carousel event --- spec/components/Carousel/Carousel.test.tsx | 4 ---- src/components/carousel.tsx | 4 +--- .../components/Carousel/CarouselEvents.stories.tsx | 2 +- .../components/Carousel/Code Examples - Events.mdx | 9 +++------ src/utils/events.ts | 2 -- 5 files changed, 5 insertions(+), 16 deletions(-) diff --git a/spec/components/Carousel/Carousel.test.tsx b/spec/components/Carousel/Carousel.test.tsx index b7bbeb0..e9269f9 100644 --- a/spec/components/Carousel/Carousel.test.tsx +++ b/spec/components/Carousel/Carousel.test.tsx @@ -616,8 +616,6 @@ describe('Carousel component', () => { expect(listener).toHaveBeenCalledTimes(1); const event = listener.mock.calls[0][0] as CustomEvent; expect(event.detail.direction).toBe('next'); - expect(typeof event.detail.canScrollNext).toBe('boolean'); - expect(typeof event.detail.canScrollPrev).toBe('boolean'); el.removeEventListener(CIO_EVENTS.carousel.next, listener); }); @@ -635,8 +633,6 @@ describe('Carousel component', () => { expect(listener).toHaveBeenCalledTimes(1); const event = listener.mock.calls[0][0] as CustomEvent; expect(event.detail.direction).toBe('previous'); - expect(typeof event.detail.canScrollNext).toBe('boolean'); - expect(typeof event.detail.canScrollPrev).toBe('boolean'); el.removeEventListener(CIO_EVENTS.carousel.previous, listener); }); diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index 03674f0..2b9cdfb 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -320,15 +320,13 @@ function CarouselNavButton({ eventName, { direction, - canScrollNext: canScrollNext ?? false, - canScrollPrev: canScrollPrev ?? false, }, e.currentTarget, ); scrollFn?.(); }, - [isPrevious, direction, canScrollNext, canScrollPrev, scrollFn], + [isPrevious, direction, scrollFn], ); const override = isPrevious diff --git a/src/stories/components/Carousel/CarouselEvents.stories.tsx b/src/stories/components/Carousel/CarouselEvents.stories.tsx index 6f0a92a..32db193 100644 --- a/src/stories/components/Carousel/CarouselEvents.stories.tsx +++ b/src/stories/components/Carousel/CarouselEvents.stories.tsx @@ -39,7 +39,7 @@ function CarouselEventListeningDemo() { const logEvent = (label: string) => (e: Event) => { const detail = (e as CustomEvent).detail; setEventLog((prev) => [ - `[${new Date().toLocaleTimeString()}] ${label} — direction: ${detail?.direction}, canScrollNext: ${detail?.canScrollNext}, canScrollPrev: ${detail?.canScrollPrev}`, + `[${new Date().toLocaleTimeString()}] ${label} — direction: ${detail?.direction}`, ...prev.slice(0, 49), ]); }; diff --git a/src/stories/components/Carousel/Code Examples - Events.mdx b/src/stories/components/Carousel/Code Examples - Events.mdx index 084af6b..2b5b646 100644 --- a/src/stories/components/Carousel/Code Examples - Events.mdx +++ b/src/stories/components/Carousel/Code Examples - Events.mdx @@ -32,8 +32,8 @@ function MyCarousel({ products }) { if (!el) return; const handleNav = (e: Event) => { - const { direction, canScrollNext, canScrollPrev } = (e as CustomEvent).detail; - console.log(`Scrolled ${direction}`, { canScrollNext, canScrollPrev }); + const { direction } = (e as CustomEvent).detail; + console.log(`Scrolled ${direction}`); }; el.addEventListener(CIO_EVENTS.carousel.next, handleNav); @@ -55,7 +55,7 @@ function MyCarousel({ products }) { ## Available Events -Both events carry a `CarouselNavEventDetail` payload with the scroll direction and current scroll state. +Both events carry a `CarouselNavEventDetail` payload with the scroll direction. | Event Name | Constant | Fires When | | ------------------------------------- | --------------------------------- | ---------------------- | @@ -67,13 +67,10 @@ Both events carry a `CarouselNavEventDetail` payload with the scroll direction a ```ts interface CarouselNavEventDetail { direction: 'next' | 'previous'; - canScrollNext: boolean; - canScrollPrev: boolean; } ``` ## Notes - All events are dispatched with `bubbles: true`, so you can listen on any ancestor element instead of the carousel itself. -- The `detail` payload includes the current scroll state (`canScrollNext`, `canScrollPrev`) so you can update UI accordingly. - Use `CIO_EVENTS` constants instead of raw strings to avoid typos and get autocomplete. diff --git a/src/utils/events.ts b/src/utils/events.ts index b423efd..b7e7b97 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -22,8 +22,6 @@ export interface ProductCardEventDetail { export interface CarouselNavEventDetail { direction: 'next' | 'previous'; - canScrollNext: boolean; - canScrollPrev: boolean; } export interface CioEventDetailMap { From a01a44732ed2886d78f060e9464760d5d24b3aa7 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Fri, 27 Feb 2026 17:27:09 +0300 Subject: [PATCH 13/15] Cleanup --- src/components/carousel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx index 2b9cdfb..db3ba91 100644 --- a/src/components/carousel.tsx +++ b/src/components/carousel.tsx @@ -128,6 +128,7 @@ function CarouselBase({ api.on('select', onSelect); return () => { + api?.off('reInit', onSelect); api?.off('select', onSelect); }; }, [api, onSelect]); From 3f15115905fb1fdacdb11ace7715497bb9f50e5a Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Fri, 27 Feb 2026 17:27:32 +0300 Subject: [PATCH 14/15] Use a different selector --- src/components/product-card.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 7433015..2513e2e 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -391,11 +391,8 @@ function ProductCard({ componentOverrides, children, className, ...props }: Prod (e: React.MouseEvent) => { const target = e.target as HTMLElement; - // Do not fire if a children button is clicked - if ( - target.closest('.cio-product-card-add-to-cart-btn') || - target.closest('.cio-product-card-wishlist-btn') - ) { + // Do not fire if a conversion button (AddToCart / Wishlist) is clicked + if (target.closest('[data-cnstrc-btn]')) { return; } From 8593efc6e58ffd32a58e9d0b88561605efee4a52 Mon Sep 17 00:00:00 2001 From: Enes Kutay SEZEN Date: Fri, 27 Feb 2026 17:27:39 +0300 Subject: [PATCH 15/15] Handle addToWishlist --- .../product-card/product-card.test.tsx | 19 +++++++++++++++++++ src/components/product-card.tsx | 14 +++++++++++++- src/utils/events.ts | 2 ++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/spec/components/product-card/product-card.test.tsx b/spec/components/product-card/product-card.test.tsx index fda8250..ae0eb28 100644 --- a/spec/components/product-card/product-card.test.tsx +++ b/spec/components/product-card/product-card.test.tsx @@ -512,6 +512,25 @@ describe('ProductCard component', () => { expect(mockOnProductClick).not.toHaveBeenCalled(); }); + test('dispatches productCard.wishlist event on root element on wishlist click', () => { + const mockOnAddToWishlist = vi.fn(); + render(); + + const el = screen.getByTestId('product-card'); + const listener = vi.fn(); + el.addEventListener(CIO_EVENTS.productCard.wishlist, listener); + + fireEvent.click(screen.getByRole('button', { name: /add to wishlist/i })); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.product).toEqual(mockProductData.product); + expect(mockOnAddToWishlist).toHaveBeenCalledTimes(1); + expect(mockOnAddToWishlist).toHaveBeenCalledWith(expect.any(Object), mockProductData.product); + + el.removeEventListener(CIO_EVENTS.productCard.wishlist, listener); + }); + test('clicking wishlist button does NOT call onProductClick callback', () => { const mockOnProductClick = vi.fn(); render( diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 2513e2e..2dfcebd 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -45,6 +45,18 @@ const WishlistButton: React.FC = (props) => { children, } = props; + const handleWishlistClick = useCallback( + (e: React.MouseEvent) => { + dispatchCioEvent( + CIO_EVENTS.productCard.wishlist, + { product: renderProps.product }, + e.currentTarget, + ); + onAddToWishlist?.(e, renderProps.product); + }, + [renderProps.product, onAddToWishlist], + ); + return ( = (props) => { size='icon' variant='secondary' conversionType='add_to_wishlist' - onClick={(e) => onAddToWishlist?.(e, renderProps.product)} + onClick={handleWishlistClick} aria-label={isInWishlist ? 'Remove from wishlist' : 'Add to wishlist'}>