diff --git a/packages/ibm-products/src/components/Coachmark/next/Coachmark/Coachmark.tsx b/packages/ibm-products/src/components/Coachmark/next/Coachmark/Coachmark.tsx index 2aac0a0f53c..97895297745 100644 --- a/packages/ibm-products/src/components/Coachmark/next/Coachmark/Coachmark.tsx +++ b/packages/ibm-products/src/components/Coachmark/next/Coachmark/Coachmark.tsx @@ -11,7 +11,6 @@ import React, { ReactNode, RefAttributes, RefObject, - createContext, forwardRef, useEffect, useRef, diff --git a/packages/ibm-products/src/components/Coachmark/next/Coachmark/CoachmarkContent.tsx b/packages/ibm-products/src/components/Coachmark/next/Coachmark/CoachmarkContent.tsx index d6baf1c3dd6..0d670ca9ebe 100644 --- a/packages/ibm-products/src/components/Coachmark/next/Coachmark/CoachmarkContent.tsx +++ b/packages/ibm-products/src/components/Coachmark/next/Coachmark/CoachmarkContent.tsx @@ -68,10 +68,10 @@ const CoachmarkContent = forwardRef( const targetId = open ? triggerRef?.current?.id : null; const handleRef = useRef(null); - const bubbleRef = ref && typeof ref !== 'function' ? ref : handleRef; + const bubbleRef = ref || handleRef; useEffect(() => { - if (open && bubbleRef.current) { + if (open && 'current' in bubbleRef && bubbleRef.current) { requestAnimationFrame(() => { const contentBody = bubbleRef.current?.querySelector( `.${contentBodyClass}` @@ -91,7 +91,8 @@ const CoachmarkContent = forwardRef( useEffect(() => { const handleOutsideClick = (event: MouseEvent) => { const targetElement = document.getElementById(targetId || ''); - const bubbleElement = bubbleRef.current; + const bubbleElement = + bubbleRef && 'current' in bubbleRef ? bubbleRef.current : null; if ( bubbleElement && @@ -122,7 +123,7 @@ const CoachmarkContent = forwardRef( }, [open, targetId, setOpen]); useEffect(() => { - if (open && bubbleRef.current) { + if (open && 'current' in bubbleRef && bubbleRef.current) { const dragContainer = bubbleRef.current.querySelector( `.${pkg.prefix}__bubble` ); @@ -131,7 +132,8 @@ const CoachmarkContent = forwardRef( } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, bubbleRef.current]); + }, [open, bubbleRef]); + return ( open && (
diff --git a/packages/ibm-products/src/global/js/utils/carousel/carousel.ts b/packages/ibm-products/src/global/js/utils/carousel/carousel.ts index 671ff77ea64..f349c8649c1 100644 --- a/packages/ibm-products/src/global/js/utils/carousel/carousel.ts +++ b/packages/ibm-products/src/global/js/utils/carousel/carousel.ts @@ -30,8 +30,12 @@ export const initCarousel = ( const minHeight = 4; // 10 rem - const { onViewChangeStart, onViewChangeEnd, excludeSwipeSupport } = - config || {}; + const { + onViewChangeStart, + onViewChangeEnd, + excludeSwipeSupport, + useMaxHeight, + } = config || {}; /** * Registers an HTMLElement at a specific index in the refs array. @@ -238,6 +242,8 @@ export const initCarousel = ( */ const performAnimation = (isInitial: boolean) => { let itemHeightSmallest = 0; + let itemHeightMaximum = 0; + Array.from(viewItems).forEach((viewItem: HTMLElement, index) => { const stackIndex = viewIndexStack.findIndex((idx) => idx === index); const stackIndexInstanceCount = previousViewIndexStack.filter( @@ -269,15 +275,25 @@ export const initCarousel = ( registerRef(index, viewItem); setTimeout(() => { - if ( - !itemHeightSmallest || - (viewItem.offsetHeight < itemHeightSmallest && - itemHeightSmallest > remToPx(minHeight)) - ) { - itemHeightSmallest = viewItem.offsetHeight; + if (useMaxHeight) { + const heights: number[] = Array.from(viewItems).map( + (viewItem) => viewItem.scrollHeight + ); + itemHeightMaximum = Math.max(...heights); + + viewItem.style.position = 'absolute'; + updateHeightForWrapper(itemHeightMaximum); + } else { + if ( + !itemHeightSmallest || + (viewItem.offsetHeight < itemHeightSmallest && + itemHeightSmallest > remToPx(minHeight)) + ) { + itemHeightSmallest = viewItem.offsetHeight; + } + viewItem.style.position = 'absolute'; + updateHeightForWrapper(itemHeightSmallest); } - viewItem.style.position = 'absolute'; - updateHeightForWrapper(itemHeightSmallest); }); const listener = (e: Event) => { diff --git a/packages/ibm-products/src/global/js/utils/carousel/types.ts b/packages/ibm-products/src/global/js/utils/carousel/types.ts index 8cf01f5e551..79e83412861 100644 --- a/packages/ibm-products/src/global/js/utils/carousel/types.ts +++ b/packages/ibm-products/src/global/js/utils/carousel/types.ts @@ -28,6 +28,7 @@ export type Config = { onViewChangeStart?: (args: CarouselResponse) => void; onViewChangeEnd?: (args: CarouselResponse) => void; excludeSwipeSupport?: boolean; + useMaxHeight?: boolean; }; interface InitCarousel { diff --git a/packages/ibm-products/src/patterns/CoachmarkStacked/CoachmarkStacked.mdx b/packages/ibm-products/src/patterns/CoachmarkStacked/CoachmarkStacked.mdx new file mode 100644 index 00000000000..d131a214171 --- /dev/null +++ b/packages/ibm-products/src/patterns/CoachmarkStacked/CoachmarkStacked.mdx @@ -0,0 +1,38 @@ +import { Story, Controls, Source, Canvas } from '@storybook/addon-docs/blocks'; +import { CodesandboxLink } from '../../global/js/utils/story-helper'; +import * as CoachmarkStackedStories from './CoachmarkStacked.stories'; +import { stackblitzPrefillConfig } from '../../../previewer/codePreviewer'; + +# CoachmarkStacked + +## Table of Contents + +- [Overview](#overview) +- [Example usage](#example-usage) + +## Overview + +The CoachmarkStacked pattern acts as a container element for onboarding and +should only be used within the scope of a Coachmark. + +To build this pattern, we recommend including the following components: + +- [Coachmark](https://carbon-for-ibm-products.netlify.app/?path=/docs/experimental-onboarding-coachmark-next--overview) +- [cds-button](https://web-components.carbondesignsystem.com/?path=/docs/components-button) +- [Carousel utility](https://github.com/carbon-design-system/carbon/blob/main/packages/utilities/src/carousel/README.md) + +## About Onboarding + +[Onboarding](https://pages.github.ibm.com/security/security-design/department/end-to-end-experiences/onboarding/overview/) +is a continuous learning methodology and framework that aims to orient, onboard, +explain, educate, and cultivate novice users into high-functioning power users. + +## Example usage + +{/* TODO: One example per designed use case. */} + + +
+ +
+
diff --git a/packages/ibm-products/src/patterns/CoachmarkStacked/CoachmarkStacked.stories.jsx b/packages/ibm-products/src/patterns/CoachmarkStacked/CoachmarkStacked.stories.jsx new file mode 100644 index 00000000000..3af92bf83fd --- /dev/null +++ b/packages/ibm-products/src/patterns/CoachmarkStacked/CoachmarkStacked.stories.jsx @@ -0,0 +1,546 @@ +/** + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { Button, Theme, Link as CarbonLink } from '@carbon/react'; +import styles from './_storybook-styles.scss?inline'; +import DocsPage from './CoachmarkStacked.mdx'; +import { Coachmark } from '../../components/Coachmark/next/Coachmark'; +import { + initCarousel, + InitCarousel, +} from '../../global/js/utils/carousel/carousel'; +import { CoachmarkTagline } from '../../components/Coachmark/next/Coachmark/CoachmarkTagline/CoachmarkTagline'; +import { Idea } from '@carbon/react/icons'; +import { pkg } from '../../settings'; +import cx from 'classnames'; + +export default { + title: 'Patterns/Coachmark Stacked', + component: () => {}, + tags: ['autodocs'], + parameters: { + styles, + docs: { + page: DocsPage, + }, + }, +}; + +//fetching theme +function useCarbonTheme() { + const [themeValue, setThemeValue] = useState(() => + document.documentElement.getAttribute('data-carbon-theme') + ); + + useEffect(() => { + const target = document.documentElement; + + // function to read the current theme + const readTheme = () => { + const newTheme = target.getAttribute('data-carbon-theme'); + setThemeValue((prev) => (prev !== newTheme ? newTheme : prev)); + }; + + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'data-carbon-theme' + ) { + readTheme(); + } + } + }); + + observer.observe(target, { + attributes: true, + attributeFilter: ['data-carbon-theme'], + }); + + //fallback - check readTheme in every 200ms + const interval = setInterval(readTheme, 200); + + return () => { + observer.disconnect(); + clearInterval(interval); + }; + }, []); + + return themeValue; +} + +const CoachmarkStackedPattern = (args) => { + const carbonTheme = useCarbonTheme(); + const [isOpen, setIsOpen] = useState(true); + + const [currentViewIndex, setCurrentViewIndex] = useState(-1); + const [lastViewIndex, setLastViewIndex] = useState(-1); + //prettier-ignore + const [openId, setOpenId] = useState(0); + //prettier-ignore + const carouselContainerRef = useRef < HTMLDivElement > (null); + //prettier-ignore + const carouselInit = useRef < InitCarousel > (null); + //prettier-ignore + const [parentHeight, setParentHeight] = useState(0); + //prettier-ignore + const stackHomeContentRef = useRef(null); + //prettier-ignore + const stackedCoachmarkContentRefs = useRef([]); + + const blockClass = `coachmark-stacked-home`; + const stackedCoachmark = `stacked-coachmark`; + const elementBlockClass = `stacked_element_content`; + + const items = [ + { + id: 1, + label: 'Example 1', + }, + { + id: 2, + label: 'Example 2', + }, + { + id: 3, + label: 'Example 3', + }, + ]; + + const nestedItems = [ + { + id: 1, + title: 'Short Coachmark', + text: 'As small as it gets.', + type: 'simple', + button: ( + + ), + }, + { + id: 2, + type: 'carousel', + pages: [ + { + id: '1', + title: 'Mid-height Coachmark', + text: ( + <> + This should be about the same height as the base stack item. +
+
+ This is known as the enrichment phase. Enrichment supports you by + emulating how an analyst would evaluate a finding—for example, by + adding context, such as whether a certain piece of data is known + to be malicious, or is linked... + + ), + button: ( + Learn more + ), + }, + { + id: '2', + title: 'Hello World', + text: 'Link opens in new tab.', + button: ( + + {' '} + Learn more{' '} + + ), + }, + ], + }, + { + id: 3, + type: 'carousel', + pages: [ + { + id: '1', + title: 'Tall Coachmark', + text: ( + <> + These alerts contain data gathered from your connected security + systems. + + ), + }, + { + id: '2', + title: 'Alerts contain evidence, known as artifacts', + text: ( + <> + These help to determine whether the alert is good or bad. And as + alerts are added to a case, they become findings. + + ), + }, + { + id: '3', + title: 'Findings are enriched with more information and context', + text: ( + <> + This is known as the enrichment phase. Enrichment supports you by + emulating how an analyst would evaluate a finding—for example, by + adding context, such as whether a certain piece of data is known + to be malicious, or is linked to a known threat. +
+
+ Lets +
+
+ make +
+
+ this +
+
+ one +
+
+ really +
+
+ tall. + + ), + }, + { + id: '4', + title: 'Next, the correlation process takes place', + text: ( + <> + Based on the results of the enrichment process, findings that are + potentially related are grouped together, and then evaluated. + + ), + }, + { + id: '5', + title: + 'Between enrichment and correlation, the severity of a case is determined', + text: ( + <> + And once you know the severity, you can easily choose which case + to pick up next. + + ), + }, + ], + }, + ]; + + const handleClose = () => { + setIsOpen(false); + }; + + const handleCloseCarousel = (e) => { + setOpenId(null); + carouselInit.current.reset(); + e.stopPropagation(); + }; + + const handleTaglineClick = () => { + setIsOpen((isOpen) => !isOpen); + }; + + useEffect(() => { + if (carouselContainerRef && carouselContainerRef.current) { + carouselInit.current = initCarousel(carouselContainerRef.current, { + onViewChangeStart: onViewChangeStart, + onViewChangeEnd: onViewChangeEnd, + useMaxHeight: true, + }); + } + }, [carouselInit, openId]); + + const onViewChangeStart = () => {}; + const onViewChangeEnd = (options) => { + handleViewStackUpdate(options); + }; + + const handleViewStackUpdate = useCallback( + ({ currentIndex, lastIndex }) => { + setCurrentViewIndex(currentIndex); + setLastViewIndex(lastIndex); + }, + [openId] + ); + + const onNext = (e) => { + carouselInit.current.next(); + e.stopPropagation(); + }; + const onPrev = (e) => { + carouselInit.current.prev(); + e.stopPropagation(); + }; + + useLayoutEffect(() => { + if (!parentHeight) { + if (stackHomeContentRef.current) { + //prettier-ignore + const stackHomeContent = stackHomeContentRef.current.querySelector(`div.${pkg.prefix}__bubble`); + if (stackHomeContent) { + const height = stackHomeContent.clientHeight; + + if (height > 0) { + setParentHeight(height); + } + } + } + return; + } + + if (stackHomeContentRef.current) { + //prettier-ignore + const stackHomeContent = stackHomeContentRef.current.querySelector(`div.${pkg.prefix}__bubble`); + if (stackHomeContent) { + stackHomeContent.style.height = `${parentHeight}px`; + } + } + + if (!isOpen || openId <= 0) { + requestAnimationFrame(() => { + if (stackHomeContentRef.current) { + //prettier-ignore + const stackHomeContent = stackHomeContentRef.current.querySelector(`div.${pkg.prefix}__bubble`); + + if (stackHomeContent) { + stackHomeContent.classList.remove(`${blockClass}--scaled-home`); + stackHomeContent.classList.add(`${blockClass}--unscaled-home`); + stackHomeContent.focus(); + } + } + return; + }, 0); + } + + if (openId > 0 && isOpen && stackedCoachmarkContentRefs.current) { + const container = stackedCoachmarkContentRefs.current[openId]; + + //prettier-ignore + const targetHome = Array.from( + container.querySelectorAll(`div.${pkg.prefix}__bubble`) + ).filter(bubble => bubble.parentElement === container); + + if (targetHome.length > 0) { + setTimeout(() => { + targetHome.forEach((bubble) => { + requestAnimationFrame(() => { + const targetHomeHeight = bubble.clientHeight; + + //prettier-ignore + const stackHomeContent = stackHomeContentRef.current.querySelector(`div.${pkg.prefix}__bubble`); + if (stackHomeContent) { + stackHomeContent.style.height = `calc(${targetHomeHeight}px + 1px)`; + stackHomeContent.classList.add(`${blockClass}--scaled-home`); + stackHomeContent.classList.remove( + `${blockClass}--unscaled-home` + ); + stackedCoachmarkContentRefs.current[openId].focus(); + } + }); + }); + }, 0); + } + } + }, [openId, isOpen, parentHeight]); + + return ( + + + + + + +
+ +
+
+

Example title

+

+ This is an example of a description +

+
+
    + {items.map((item) => ( + +
  • + +
  • +
    + ))} +
+ +

+
+
+
+ {items.map((item) => ( + { + setOpenId(null); + e.stopPropagation(); + }} + align="top" + > + { + if (el) { + stackedCoachmarkContentRefs.current[item.id] = el; + } + }} + className={cx(`${elementBlockClass}`)} + > + + + {nestedItems + .filter((nested) => nested.id === item.id) + .map((nested) => { + if (nested.type === 'simple') { + return ( +
+

+ {nested.title} +

+

+ {nested.text} +

+
+ {nested.button} +
+
+ ); + } + if (nested.type === 'carousel') { + return ( + <> +
+ {nested.pages.map((page) => ( +
+

{page.title}

+

{page.text}

+

{page.button}

+
+ ))} +
+ +
+
+ {nested.pages.map((page, index) => { + if ( + carouselInit.current?.getActiveItem?.() + ?.index === index + ) + return ( + + {`${carouselInit.current?.getActiveItem?.()?.index + 1} / ${nested.pages.length}`} + + ); + })} +
+
+ {currentViewIndex !== 0 && ( + + )} + + {lastViewIndex !== currentViewIndex ? ( + + ) : ( + + )} +
+
+ + ); + } + return null; + })} +
+
+
+ ))} +
+ ); +}; + +export const CoachmarkStack = CoachmarkStackedPattern.bind({}); +CoachmarkStack.args = {}; diff --git a/packages/ibm-products/src/patterns/CoachmarkStacked/_storybook-styles.scss b/packages/ibm-products/src/patterns/CoachmarkStacked/_storybook-styles.scss new file mode 100644 index 00000000000..cbc54306e83 --- /dev/null +++ b/packages/ibm-products/src/patterns/CoachmarkStacked/_storybook-styles.scss @@ -0,0 +1,185 @@ +// +// Copyright IBM Corp. 2025 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +/* stylelint-disable declaration-no-important */ + +@use 'ALIAS_STORY_STYLE_CONFIG' as c4p-settings; +@use '@carbon/styles/scss/spacing' as *; +@use '@carbon/styles/scss/theme' as *; +@use '@carbon/styles/scss/themes' as *; +@use '@carbon/styles/scss/type'; +@use '../../global/js/utils/carousel/index' as carousel; +@use '@carbon/styles/scss/motion' as *; +@use '@carbon/layout/scss/convert'; +@use '@carbon/styles/scss/colors' as *; + +@use '@carbon/styles/scss/components/button/tokens' as *; + +@include carousel.carousel; + +$block-class: #{c4p-settings.$pkg-prefix}--coachmark__next; +$stack-home-class: 'coachmark-stacked-home'; +$stacked-coachmark: 'stacked-coachmark'; + +$elements-block-class: 'coachmark-stack-element'; + +// BUBBLE - content styling +.#{$block-class}--coachmark-content { + position: fixed; + inline-size: 18rem; + margin-inline-start: $spacing-04; + visibility: visible; + + &.--is-visible { + visibility: visible; + } + + .#{$block-class}--content-body { + display: flex; + flex-direction: column; + } + + .#{$block-class}--content-body > h2 { + @include type.type-style('productive-heading-02'); + + margin: 0 0 $spacing-03; + } + + .#{$block-class}--content-body > p { + @include type.type-style('body-long-01'); + } + + .#{$block-class}--content-body > button { + display: flex; + align-self: flex-end; + margin-block-start: $spacing-05; + } +} + +.carousel__view > h2 { + @include type.type-style('productive-heading-02'); + + margin: 0 0 $spacing-03; +} + +.carousel__view > p { + @include type.type-style('body-long-01'); +} + +.carouselControls { + position: relative; + + .nextButton { + position: absolute; + inset-inline-start: $spacing-07; + } + + .prevButton { + position: absolute; + inset-inline-start: $spacing-07; + } +} + +.cds--content-switcher { + inline-size: 20rem; + margin-block: $spacing-09; + margin-inline: $spacing-07; +} + +.carouselControlWrapper__footer { + span { + justify-self: flex-start; + margin-inline-end: auto; + } +} + +.carouselControlWrapper__footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding-block-start: $spacing-05; +} + +.carouselControlWrapper--controls-progress { + justify-self: flex-start; + margin-inline-end: auto; + @include type.type-style('helper-text-01'); +} + +// SPECIFIC TO THE HOME ELEMENT +.#{$stack-home-class}, +.#{$stacked-coachmark} { + &__nav-links { + margin-block-start: $spacing-04; + margin-inline-start: calc(-1 * $spacing-05); + } + + &__icon-idea { + margin-block-end: $spacing-03; + } + + li { + display: block; + max-inline-size: 100%; + white-space: nowrap; + } + + &__title { + @include type.type-style('productive-heading-02'); + + margin: 0 0 $spacing-03; + } + + &__body { + @include type.type-style('body-long-01'); + } + + &__button { + margin-block-end: 0; + } +} + +.#{$stacked-coachmark} { + &__button { + display: flex; + align-items: center; + justify-content: flex-end; + padding: $spacing-05 $spacing-05 0 $spacing-05; + } +} + +.#{c4p-settings.$pkg-prefix}__bubble__arrow { + display: none; +} + +$elements-block-class: 'stacked_element_content'; + +.#{$elements-block-class} { + inset: logical unset unset 0 0 !important; + inset-block-start: auto; + inset-inline-start: auto; +} +.#{c4p-settings.$pkg-prefix}--coachmark__next--coachmark-content { + inset-block-end: 0; + inset-block-start: unset !important; + inset-inline-end: $spacing-05; + inset-inline-start: unset !important; + opacity: 1; + transform: none; +} + +.#{$stack-home-class}--scaled-home { + inset-block-end: $spacing-05; + opacity: 0.8; + transform: scale(0.9); + transform-origin: top center; + transition: transform $duration-moderate-02 motion(exit, productive); +} + +.#{$stack-home-class}--unscaled-home { + transform: none; +}