[CDX-376] Emit events on user interaction - Part 2#36
Conversation
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
Updates UI component event emission to support scoped, bubbling CustomEvents dispatched from component root elements (via refs), while keeping window-based listeners working for backwards compatibility.
Changes:
- Enhance
dispatchCioEventto optionally dispatch on a provided DOM target and enable bubbling. - Convert
ProductCard,Carousel, andCardtoforwardRefcomponents and dispatch events from their root elements. - Add Storybook MDX docs/demos for scoped event listening and enable GFM tables via
remark-gfm.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/events.ts | Adds optional target dispatch and enables bubbling for scoped event listening. |
| src/types/carouselTypes.ts | Extends carousel context typing to include a root ref for scoped dispatch. |
| src/components/product-card.tsx | Adds forwardRef, stores root element ref, and dispatches events on the component root. |
| src/components/carousel.tsx | Adds forwardRef, stores root element ref, and dispatches nav events on the component root. |
| src/components/card.tsx | Converts Card root to forwardRef so parents can attach refs to the root element. |
| spec/utils/events.test.ts | Updates existing expectations and adds coverage for target dispatch + bubbling + fallbacks. |
| spec/components/product-card/product-card.test.tsx | Updates tests to listen on root refs; adds bubbling + non-cross-pollination coverage. |
| spec/components/Carousel/Carousel.test.tsx | Updates tests to listen on root refs; adds bubbling + non-cross-pollination coverage. |
| src/stories/components/ProductCard/ProductCardEvents.stories.tsx | Adds interactive demo story for scoped ProductCard event listening. |
| src/stories/components/ProductCard/Code Examples - Events.mdx | Adds MDX docs page with a GFM table of events and example code. |
| src/stories/components/Carousel/CarouselEvents.stories.tsx | Adds interactive demo story for scoped Carousel event listening. |
| src/stories/components/Carousel/Code Examples - Events.mdx | Adds MDX docs page with a GFM table of events and example code. |
| package.json | Adds remark-gfm to support MDX markdown tables. |
| .storybook/main.ts | Configures Storybook docs to use remark-gfm for MDX compilation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * 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`. |
There was a problem hiding this comment.
The JSDoc says that when no target is provided “(or during SSR)”, it falls back to dispatching on window, but the implementation always no-ops when window is undefined. Update the doc to reflect actual behavior (SSR no-op), or adjust the SSR guard logic so that providing a non-window target can still dispatch when window is missing (only if CustomEvent is available in that environment).
| * 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`. | |
| * any ancestor. When no target is provided in a browser environment, this | |
| * falls back to dispatching on `window` for backwards compatibility. In | |
| * non-browser environments (e.g., SSR) where `window` is undefined, this | |
| * function is a no-op and does not dispatch any event. | |
| * | |
| * @param eventName - A key from {@link CioEventDetailMap} (use `CIO_EVENTS` constants). | |
| * @param detail - The strongly-typed payload for the event. | |
| * @param target - Optional EventTarget to dispatch the event on. When omitted | |
| * in a browser environment, the event is dispatched on `window`. |
src/components/carousel.tsx
Outdated
| // (e.g. ref.current.addEventListener(CIO_EVENTS.carousel.next, handler)) | ||
| // Type assertion preserves generic support that forwardRef normally erases | ||
| const Carousel = forwardRef(CarouselInner) as <T = Product>( | ||
| props: CarouselOpts<T> & { ref?: React.Ref<HTMLDivElement> }, |
There was a problem hiding this comment.
Typing the forwarded-ref component as CarouselOpts<T> & { ref?: ... } is misleading because ref is not a normal prop in React; it’s provided via RefAttributes. Prefer typing this as CarouselOpts<T> & React.RefAttributes<HTMLDivElement> (or PropsWithoutRef<...> & RefAttributes<...>) to match React’s conventions and avoid consumers treating ref like a regular prop.
| props: CarouselOpts<T> & { ref?: React.Ref<HTMLDivElement> }, | |
| props: CarouselOpts<T> & React.RefAttributes<HTMLDivElement>, |
src/components/product-card.tsx
Outdated
| dispatchCioEvent(CIO_EVENTS.productCard.click, { product }); | ||
| dispatchCioEvent(CIO_EVENTS.productCard.click, { product }, rootRef.current); | ||
| onProductClick?.(); | ||
| }, [product, onProductClick]); |
There was a problem hiding this comment.
Other callbacks in this file include rootRef in their dependency arrays for consistency, but handleProductClick does not. Even though useRef is stable, keeping dependency patterns consistent helps avoid confusion and reduces lint-rule exceptions; consider adding rootRef to the dependency array here as well.
| }, [product, onProductClick]); | |
| }, [product, onProductClick, rootRef]); |
src/components/product-card.tsx
Outdated
| const setRootRef = useCallback( | ||
| (node: HTMLDivElement | null) => { | ||
| (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node; | ||
| if (typeof ref === 'function') { | ||
| ref(node); | ||
| } else if (ref) { | ||
| (ref as React.MutableRefObject<HTMLDivElement | null>).current = node; | ||
| } | ||
| }, | ||
| [ref], | ||
| ); |
There was a problem hiding this comment.
The casts to React.MutableRefObject are unnecessary for rootRef because useRef already returns a mutable ref (rootRef.current = node works). Simplifying this (and potentially extracting a small shared mergeRefs helper) would reduce verbosity and duplication, especially since similar ref-merging logic exists in CarouselBase.
This comment has been minimized.
This comment has been minimized.
c93fdcc
into
cdx-376-components-ui-emit-events-on-user-interaction
Code Review ResultsStrengthsThe pub-sub event architecture is well-designed: events are dispatched on the source element with bubbles: true so consumers can listen on any ancestor, the type system is strong with CioEventDetailMap providing typed payloads, test coverage is thorough with isolation tests (cross-pollination), and the Storybook documentation with live event-log demos is excellent. Critical IssuesNone. Important Issues[File: src/components/carousel.tsx Line: L131] The reInit listener is registered but never cleaned up in the useEffect return. Only the select listener is removed. This is a memory leak: if the carousel re-mounts or the api changes, the old reInit handler accumulates. The cleanup should also call api?.off('reInit', onSelect). [File: src/components/product-card.tsx Lines: L392-L398] The handleProductClick / AddToCartButton interaction relies implicitly on e.stopPropagation() in AddToCartButton to prevent productCard.click from also being dispatched on add-to-cart clicks. This implicit coupling is fragile: removing stopPropagation from the button (e.g. during a refactor) would silently break the event contract. A comment documenting this dependency would prevent future regressions. [File: src/stories/components/Carousel/CarouselEvents.stories.tsx Lines: L24-L28] price, salePrice, and reviewsCount are generated with Math.random(), producing non-deterministic values in Storybook. Use fixed values to keep story renders reproducible for visual regression tests. Additionally, reviewsCount is typed as number here (Math.floor(...)) while the test fixtures in Carousel.test.tsx use strings (e.g. '100'). This inconsistency can mask type-narrowing bugs -- align story mock data to use strings consistently. Suggestions[File: src/utils/events.ts Line: L51] Consider exporting a type CioEventName = keyof CioEventDetailMap helper so consumers who want to write typed addEventListener wrappers can do so without importing both CIO_EVENTS and the full interface map separately. [File: spec/components/product-card/product-card.test.tsx Line: L538] The long inline object literal for product2 exceeds the project line length and should be extracted to a named constant at the top of the Pub-Sub Events describe block, consistent with how mockProductData is defined at the top of the file. [File: src/stories/components/ProductCard/ProductCardEvents.stories.tsx Lines: L48-L61] The ProductCard JSX block has inconsistent indentation relative to its parent (6-space indent vs. 2-space used elsewhere). Aligning it would match the project Prettier config. Overall Assessment: Needs Work |
* Implementation * Tests * Cleanup * Test no-op * Stop propagation * [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 * Remove stopPropagation * remove unnecessary test * Bring back comment * Add product to callbacks * lint * Simplify carousel event * Cleanup * Use a different selector * Handle addToWishlist
Continuation of #35. Emits events on the parent element rather than window.
Why did I introduce a new package for Markdown?