diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
new file mode 100644
index 00000000..d00813f0
--- /dev/null
+++ b/.github/workflows/sonar.yml
@@ -0,0 +1,20 @@
+name: Sonar scan
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ types: [opened, synchronize, reopened]
+jobs:
+ sonarcloud:
+ name: SonarCloud
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+ - name: SonarCloud Scan
+ uses: SonarSource/sonarcloud-github-action@master
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 8ab3f3b6..c7c7858d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,7 @@
.pnp.js
# testing
-/coverage
+# /coverage
# production
/build
@@ -37,5 +37,5 @@ cache
cypress/videos
.scannerwork
-sonar-project.properties
+# sonar-project.properties
react-app-tester
\ No newline at end of file
diff --git a/coverage/clover.xml b/coverage/clover.xml
new file mode 100644
index 00000000..827b687c
--- /dev/null
+++ b/coverage/clover.xml
@@ -0,0 +1,5524 @@
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x + | /* eslint-disable react/prop-types */ +import { TimelineProps as PropsModel } from '@models/TimelineModel'; +import { + getDefaultButtonTexts, + getDefaultClassNames, + getDefaultThemeOrDark, + getSlideShowType, +} from '@utils/index'; +import { + createContext, + FunctionComponent, + useCallback, + useMemo, + useState, +} from 'react'; + +const GlobalContext = createContext< + PropsModel & { toggleDarkMode?: () => void } +>({}); + +type ContextProps = PropsModel & { + toggleDarkMode?: () => void; +}; + +const GlobalContextProvider: FunctionComponent<Partial<PropsModel>> = ( + props, +) => { + const { + cardHeight = 200, + cardLess = false, + flipLayout, + items = [], + theme, + buttonTexts, + classNames, + mode = 'VERTICAL_ALTERNATING', + fontSizes, + textOverlay, + darkMode, + slideShow, + onThemeChange, + mediaSettings, + mediaHeight = 200, + contentDetailsHeight = 10, + } = props; + + const [isDarkMode, setIsDarkMode] = useState(darkMode); + + const newCardHeight = useMemo( + () => Math.max(contentDetailsHeight || 0 + mediaHeight || 0, cardHeight), + [], + ); + + const newContentDetailsHeight = useMemo(() => { + const detailsHeightApprox = Math.round(newCardHeight * 0.75); + return contentDetailsHeight > newCardHeight + ? Math.min(contentDetailsHeight, detailsHeightApprox) + : Math.max(contentDetailsHeight, detailsHeightApprox); + }, [newCardHeight]); + + const toggleDarkMode = useCallback(() => { + setIsDarkMode(!isDarkMode); + onThemeChange?.(); + }, [isDarkMode]); + + const defaultProps = useMemo( + () => + Object.assign<ContextProps, ContextProps, ContextProps>( + {}, + { + borderLessCards: false, + cardHeight: newCardHeight, + cardLess: false, + disableAutoScrollOnClick: false, + disableClickOnCircle: false, + enableBreakPoint: true, + enableDarkToggle: false, + focusActiveItemOnLoad: false, + lineWidth: 3, + mediaHeight: 200, + nestedCardHeight: 150, + scrollable: { + scrollbar: false, + }, + showAllCardsHorizontal: false, + showProgressOnSlideshow: slideShow, + slideItemDuration: 2000, + slideShowType: getSlideShowType(mode), + textOverlay: false, + timelinePointDimension: 16, + timelinePointShape: 'circle', + titleDateFormat: 'MMM DD, YYYY', + uniqueId: 'react-chrono', + useReadMore: true, + verticalBreakPoint: 1028, + }, + { + ...props, + activeItemIndex: flipLayout ? items?.length - 1 : 0, + buttonTexts: { + ...getDefaultButtonTexts(), + ...buttonTexts, + }, + cardHeight: cardLess ? cardHeight || 80 : cardHeight, + classNames: { + ...getDefaultClassNames(), + ...classNames, + }, + contentDetailsHeight: newContentDetailsHeight, + darkMode: isDarkMode, + fontSizes: { + cardSubtitle: '0.85rem', + cardText: '1rem', + cardTitle: '1rem', + title: '1rem', + ...fontSizes, + }, + mediaSettings: { + align: mode === 'VERTICAL' && !textOverlay ? 'left' : 'center', + imageFit: 'cover', + ...mediaSettings, + }, + theme: { + ...getDefaultThemeOrDark(isDarkMode), + ...theme, + }, + toggleDarkMode, + }, + ), + [newContentDetailsHeight, newCardHeight, isDarkMode, toggleDarkMode], + ); + + const { children } = props; + + return ( + <GlobalContext.Provider + value={{ ...defaultProps, darkMode: isDarkMode, toggleDarkMode }} + > + {children} + </GlobalContext.Provider> + ); +}; + +export default GlobalContextProvider; + +export { GlobalContext }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
index.ts | +
+
+ |
+ 100% | +19/19 | +100% | +3/3 | +100% | +2/2 | +100% | +19/19 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { css } from 'styled-components'; + +export const ScrollBar = css` + scrollbar-color: ${(p) => p.theme?.primary} default; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 0.3em; + } + + &::-webkit-scrollbar-track { + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); + } + + &::-webkit-scrollbar-thumb { + background-color: ${(p) => p.theme?.primary}; + outline: 1px solid ${(p) => p.theme?.primary}; + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
index.tsx | +
+
+ |
+ 100% | +83/83 | +100% | +1/1 | +14.28% | +1/7 | +100% | +83/83 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +41x +41x +41x +41x +41x +41x +41x +41x + | import { TimelineProps } from '@models/TimelineModel'; +import { render, RenderResult } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { GlobalContext } from '../../GlobalContext'; + +export const providerProps: TimelineProps = { + buttonTexts: { + dark: 'dark', + first: 'first', + last: 'last', + light: 'light', + next: 'next', + play: 'start slideshow', + previous: 'previous', + stop: 'stop slideshow', + }, + classNames: { + card: 'card', + cardMedia: 'card-media', + cardSubTitle: 'card-subtitle', + cardText: 'card-text', + cardTitle: 'card-title', + controls: 'controls', + title: 'title', + }, + darkMode: false, + enableDarkToggle: true, + fontSizes: { + cardSubtitle: '0.85rem', + cardText: '1rem', + cardTitle: '1.25rem', + title: '1.5rem', + }, + mediaHeight: 200, + mode: 'VERTICAL_ALTERNATING', + scrollable: { + scrollbar: false, + }, + showAllCardsHorizontal: false, + showProgressOnSlideshow: false, + slideItemDuration: 2000, + slideShowType: 'reveal', + textOverlay: false, + theme: { + cardBgColor: '#fff', + cardDetailsBackGround: '#ffffff', + cardDetailsColor: '#000', + cardSubtitleColor: '#000', + cardTitleColor: '#000', + detailsColor: '#000', + primary: '#0f52ba', + secondary: '#ffdf00', + titleColor: '#0f52ba', + titleColorActive: '#0f52ba', + }, + timelinePointDimension: 16, + timelinePointShape: 'circle', + titleDateFormat: 'MMM DD, YYYY', + useReadMore: true, +}; + +export const commonProps = { + disableLeft: false, + disableRight: false, + onFirst: () => {}, + onLast: () => {}, + onNext: () => {}, + onPrevious: () => {}, + onReplay: () => {}, + onToggleDarkMode: () => {}, + slideShowEnabled: false, + slideShowRunning: false, +}; + +export const customRender = ( + ui: ReactElement, + { providerProps, ...renderOptions }: any, +): RenderResult => { + return render( + <GlobalContext.Provider value={providerProps}>{ui}</GlobalContext.Provider>, + renderOptions, + ); +}; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
index.ts | +
+
+ |
+ 100% | +41/41 | +100% | +0/0 | +100% | +0/0 | +100% | +41/41 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; + +export const defaultTheme: Theme = { + cardBgColor: '#ffffff', + cardDetailsBackGround: '#ffffff', + cardDetailsColor: '#000', + cardMediaBgColor: '#f5f5f5', + cardSubtitleColor: '#000', + cardTitleColor: '#007FFF', + detailsColor: '#000', + iconBackgroundColor: '#007FFF', + nestedCardBgColor: '#f5f5f5', + nestedCardDetailsBackGround: '#f5f5f5', + nestedCardDetailsColor: '#000', + nestedCardSubtitleColor: '#000', + nestedCardTitleColor: '#000', + primary: '#007FFF', + secondary: '#ffdf00', + titleColor: '#007FFF', + titleColorActive: '#007FFF', +}; + +export const darkTheme: Theme = { + cardBgColor: '#191919', + cardDetailsBackGround: '#191919', + cardDetailsColor: '#ffff0f', + cardMediaBgColor: '#2f2f2f', + cardSubtitleColor: '#ffffff', + cardTitleColor: '#007FFF', + detailsColor: '#ffffff', + iconBackgroundColor: '#007FFF', + nestedCardBgColor: '#333333', + nestedCardDetailsBackGround: '#333333', + nestedCardDetailsColor: '#ffffff', + nestedCardSubtitleColor: '#ffffff', + nestedCardTitleColor: '#ffffff', + primary: '#007FFF', + secondary: '#ffdf00', + titleColor: '#007FFF', + titleColorActive: '#007FFF', +}; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
useMatchMedia.ts | +
+
+ |
+ 95.65% | +44/46 | +90% | +9/10 | +50% | +1/2 | +95.65% | +44/46 | +
useNewScrollPosition.ts | +
+
+ |
+ 15.9% | +14/88 | +100% | +0/0 | +0% | +0/1 | +15.9% | +14/88 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +3x +1x +1x +2x +2x +2x +2x +2x +3x +1x +1x +2x +2x +2x +2x +2x +2x +4x +4x +4x +4x + + +4x +4x +4x +4x + | /** + * The useMatchMedia hook takes a media query string, a callback function, and an enabled boolean. + * It returns a boolean indicating if the media query matches the current viewport and executes the callback if it does. + * + * @param {string} query - The media query string to match against. + * @param {() => void} [cb] - Optional callback function to be executed if the media query matches. + * @param {boolean} [enabled=true] - Whether the hook is enabled or not. + * @returns {boolean} - Whether the media query matches the current viewport. + */ +import { useEffect, useState } from 'react'; + +export const useMatchMedia = ( + query: string, + cb?: () => void, + enabled = true, +) => { + const [matches, setMatches] = useState<boolean>(false); + + useEffect(() => { + if (!enabled) { + return; + } + + const media = window.matchMedia(query); + const listener = () => setMatches(media.matches); + + // Check initial match and update state if necessary + if (media.matches !== matches) { + setMatches(media.matches); + } + + media.addEventListener('change', listener); + + return () => { + media.removeEventListener('change', listener); + }; + }, [query, enabled]); + + useEffect(() => { + if (matches && cb) { + cb(); + } + }, [matches, cb]); + + return matches; +}; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x + | import { Scroll } from '@models/TimelineHorizontalModel'; +import { TimelineMode } from '@models/TimelineModel'; +import { useMemo, useState } from 'react'; + +/** + * Hook to calculate the new scroll position based on the given mode and item width. + * + * @param {TimelineMode} mode - The mode of the timeline (HORIZONTAL, VERTICAL, or VERTICAL_ALTERNATING). + * @param {number} [itemWidth] - Optional item width for horizontal mode. + * @returns {[number, (e: HTMLElement, s: Partial<Scroll>) => void]} - The new offset and a function to compute the new offset. + */ +const useNewScrollPosition = ( + mode: TimelineMode, + itemWidth?: number, +): [number, (e: HTMLElement, s: Partial<Scroll>) => void] => { + // State to hold the new offset value + const [newOffset, setOffset] = useState(0); + + // Memoized function to compute the new offset value + const computeNewOffset = useMemo( + () => (parent: HTMLElement, scroll: Partial<Scroll>) => { + // Destructuring relevant properties from parent and scroll + const { clientWidth, scrollLeft, scrollTop, clientHeight } = parent; + const { pointOffset, pointWidth, contentHeight, contentOffset } = scroll; + + // Handling horizontal mode + if (mode === 'HORIZONTAL' && itemWidth && pointWidth && pointOffset) { + // Calculating right boundaries for container and circular element + const contrRight = scrollLeft + clientWidth; + const circRight = pointOffset + pointWidth; + + // Checking if the element is fully visible + const isVisible = pointOffset >= scrollLeft && circRight <= contrRight; + + // Checking if the element is partially visible + const isPartiallyVisible = + (pointOffset < scrollLeft && circRight > scrollLeft) || + (circRight > contrRight && pointOffset < contrRight); + + // Calculating gaps from left and right + const leftGap = pointOffset - scrollLeft; + const rightGap = contrRight - pointOffset; + + // Setting offset based on visibility and gap conditions + if ( + !(isVisible || isPartiallyVisible) || + (leftGap <= itemWidth && leftGap >= 0) || + (rightGap <= itemWidth && rightGap >= 0) + ) { + setOffset(pointOffset - itemWidth); + } + } else if (mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING') { + // Handling vertical modes + if (contentOffset && contentHeight) { + // Calculating bottom boundaries for container and circular element + const contrBottom = scrollTop + clientHeight; + const circBottom = contentOffset + contentHeight; + + // Checking if the element is fully visible + const isVisible = + contentOffset >= scrollTop && circBottom <= contrBottom; + + // Checking if the element is partially visible + const isPartiallyVisible = + (contentOffset < scrollTop && circBottom > scrollTop) || + (circBottom > contrBottom && contentOffset < contrBottom); + + // Calculating new offset + const nOffset = contentOffset - contentHeight; + const notVisible = !isVisible || isPartiallyVisible; + + // Setting offset based on visibility conditions + if (notVisible && nOffset + contentHeight < contrBottom) { + setOffset(nOffset + Math.round(contentHeight / 2)); + } else if (notVisible) { + setOffset(nOffset); + } + } + } + }, + [mode, itemWidth], // Dependencies for useMemo + ); + + // Returning the new offset and the function to compute it + return [newOffset, computeNewOffset]; +}; + +export default useNewScrollPosition; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 | 1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +1x +1x +1x + | import React from 'react'; + +const ChevronLeft: React.FunctionComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="feather feather-chevron-left" + > + <polyline points="15 18 9 12 15 6"></polyline> + </svg> +); + +export default ChevronLeft; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 | 1x +1x +1x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +1x +1x +1x + | import React from 'react'; + +const ChevronRightIcon: React.FunctionComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="feather feather-chevron-right" + > + <polyline points="9 18 15 12 9 6"></polyline> + </svg> +); + +export default ChevronRightIcon; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 | 1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +1x +1x +1x + | import React from 'react'; + +const ChevronLeft: React.FunctionComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="feather feather-chevrons-left" + > + <polyline points="11 17 6 12 11 7"></polyline> + <polyline points="18 17 13 12 18 7"></polyline> + </svg> +); + +export default ChevronLeft; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 | 1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +1x +1x +1x + | import React from 'react'; + +const ChevronRightIcon: React.FunctionComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="feather feather-chevrons-right" + > + <polyline points="13 17 18 12 13 7"></polyline> + <polyline points="6 17 11 12 6 7"></polyline> + </svg> +); + +export default ChevronRightIcon; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 | 1x +1x + + + + + + + + + + + + + + + + + + +1x +1x + | import * as React from "react" + +function SvgComponent() { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={24} + height={24} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + className="prefix__feather prefix__feather-x" + > + <path d="M18 6L6 18M6 6l12 12" /> + </svg> + ) +} + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
chev-left.tsx | +
+
+ |
+ 100% | +20/20 | +100% | +1/1 | +100% | +1/1 | +100% | +20/20 | +
chev-right.tsx | +
+
+ |
+ 100% | +20/20 | +100% | +1/1 | +100% | +1/1 | +100% | +20/20 | +
chevs-left.tsx | +
+
+ |
+ 100% | +21/21 | +100% | +1/1 | +100% | +1/1 | +100% | +21/21 | +
chevs-right.tsx | +
+
+ |
+ 100% | +21/21 | +100% | +1/1 | +100% | +1/1 | +100% | +21/21 | +
close.tsx | +
+
+ |
+ 18.18% | +4/22 | +100% | +0/0 | +0% | +0/1 | +18.18% | +4/22 | +
index.tsx | +
+
+ |
+ 100% | +9/9 | +100% | +0/0 | +100% | +0/0 | +100% | +9/9 | +
maximize.tsx | +
+
+ |
+ 100% | +17/17 | +100% | +1/1 | +100% | +1/1 | +100% | +17/17 | +
menu.tsx | +
+
+ |
+ 18.18% | +4/22 | +100% | +0/0 | +0% | +0/1 | +18.18% | +4/22 | +
minimize.tsx | +
+
+ |
+ 31.25% | +5/16 | +100% | +0/0 | +0% | +0/1 | +31.25% | +5/16 | +
minus.tsx | +
+
+ |
+ 100% | +16/16 | +100% | +1/1 | +100% | +1/1 | +100% | +16/16 | +
moon.tsx | +
+
+ |
+ 100% | +16/16 | +100% | +1/1 | +100% | +1/1 | +100% | +16/16 | +
plus.tsx | +
+
+ |
+ 31.25% | +5/16 | +100% | +0/0 | +0% | +0/1 | +31.25% | +5/16 | +
replay-icon.tsx | +
+
+ |
+ 100% | +19/19 | +100% | +1/1 | +100% | +1/1 | +100% | +19/19 | +
stop.tsx | +
+
+ |
+ 29.41% | +5/17 | +100% | +0/0 | +0% | +0/1 | +29.41% | +5/17 | +
sun.tsx | +
+
+ |
+ 27.77% | +5/18 | +100% | +0/0 | +0% | +0/1 | +27.77% | +5/18 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 | 1x +1x +1x +1x +1x +1x +1x +1x +1x + | export { default as ChevronLeft } from './chev-left'; +export { default as ChevronRight } from './chev-right'; +export { default as MaximizeIcon } from './maximize'; +export { default as MinimizeIcon } from './minimize'; +export { default as MinusIcon } from './minus'; +export { default as MoonIcon } from './moon'; +export { default as PlusIcon } from './plus'; +export { default as StopIcon } from "./stop"; +export { default as SunIcon } from './sun'; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 | 1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + className="feather feather-maximize-2" + > + <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 | 1x +1x + + + + + + + + + + + + + + + + + + +1x +1x + | import * as React from "react" + +function SvgComponent() { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={24} + height={24} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + className="prefix__feather prefix__feather-menu" + > + <path d="M3 12h18M3 6h18M3 18h18" /> + </svg> + ) +} + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 | 1x +1x + + + + + + + + + + + +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 | 1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M5 12h14" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 | 1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + viewBox="0 0 24 24" + > + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 | 1x +1x + + + + + + + + + + + +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M12 5v14M5 12h14" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import React from 'react'; + +const ReplayIcon: React.FunctionComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <polygon points="5 3 19 12 5 21 5 3"></polygon> + </svg> +); + +export default ReplayIcon; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 | 1x +1x + + + + + + + + + + + + +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + viewBox="0 0 24 24" + > + <circle cx={12} cy={12} r={10} /> + <path d="M9 9h6v6H9z" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 | 1x +1x + + + + + + + + + + + + + +1x +1x +1x + | +const SvgComponent = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + className="feather feather-sun" + > + <circle cx={12} cy={12} r={5} /> + <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /> + </svg> +) + +export default SvgComponent + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
GlobalContext.tsx | +
+
+ |
+ 19.86% | +29/146 | +100% | +0/0 | +0% | +0/1 | +19.86% | +29/146 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
index.tsx | +
+
+ |
+ 95.97% | +167/174 | +80.64% | +25/31 | +33.33% | +1/3 | +95.97% | +167/174 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +23x +23x +23x +23x +23x +23x +23x +23x +23x +23x +23x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +3x +3x +3x +13x +13x +16x +16x +7x +23x +1x +1x +1x +1x +1x +21x +17x +17x +17x +17x +17x +17x +17x +17x +17x +17x +4x +1x + +1x +1x +1x +1x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + +2x +1x +1x +1x +1x +1x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + +2x +1x +1x +1x +1x +1x +1x +17x +17x +17x +17x +17x +17x +17x +17x +17x +6x +3x +3x +17x +17x +17x +17x +17x +17x +17x +16x +17x +1x +1x +17x +17x +17x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +14x +17x +1x + + + + +1x +1x +1x +1x +1x + | import cls from 'classnames'; +import React, { memo, useCallback, useMemo } from 'react'; +import { hexToRGBA } from '../../../utils'; +import { MaximizeIcon, MinimizeIcon, MinusIcon, PlusIcon } from '../../icons'; +import { + CardSubTitle, + CardTitle, + CardTitleAnchor, +} from '../timeline-card-content/timeline-card-content.styles'; +import { + ExpandButton, + ShowHideTextButton, +} from '../timeline-card-media/timeline-card-media-buttons'; +import { DetailsTextWrapper } from './../timeline-card-media/timeline-card-media.styles'; +import { + Content, + DetailsTextMemoModel, + ExpandButtonModel, + ShowHideTextButtonModel, + Title, +} from './memoized-model'; + +const TitleMemo = ({ + title, + url, + theme, + color, + dir, + active, + fontSize = '1rem', + classString = '', + padding = false, +}: Title) => { + return title ? ( + <CardTitle + className={cls(active ? 'active' : '', { [classString]: true })} + theme={theme} + style={{ color }} + dir={dir} + $fontSize={fontSize} + data-class={classString} + $padding={padding} + > + {url ? ( + <CardTitleAnchor href={url} target="_blank" rel="noreferrer"> + {title} + </CardTitleAnchor> + ) : ( + title + )} + </CardTitle> + ) : null; +}; + +TitleMemo.displayName = 'Timeline Title'; + +const SubTitleMemo = React.memo<Content>( + ({ content, color, dir, theme, fontSize, classString, padding }: Content) => + content ? ( + <CardSubTitle + style={{ color }} + dir={dir} + theme={theme} + $fontSize={fontSize} + className={cls('card-sub-title', classString)} + $padding={padding} + > + {content} + </CardSubTitle> + ) : null, + (prev, next) => + prev.theme?.cardSubtitleColor === next.theme?.cardSubtitleColor, +); + +SubTitleMemo.displayName = 'Timeline Content'; + +export const ExpandButtonMemo = memo<ExpandButtonModel>( + ({ theme, expanded, onExpand, textOverlay }: ExpandButtonModel) => { + const label = useMemo(() => { + return expanded ? 'Minimize' : 'Maximize'; + }, [expanded]); + + return textOverlay ? ( + <ExpandButton + onPointerDown={onExpand} + onKeyDown={(ev) => ev.key === 'Enter' && onExpand?.(ev)} + theme={theme} + aria-expanded={expanded} + tabIndex={0} + aria-label={label} + title={label} + > + {expanded ? <MinimizeIcon /> : <MaximizeIcon />} + </ExpandButton> + ) : null; + }, + (prev, next) => prev.expanded === next.expanded, +); + +ExpandButtonMemo.displayName = 'Expand Button'; + +export const ShowOrHideTextButtonMemo = memo<ShowHideTextButtonModel>( + ({ textOverlay, onToggle, theme, show }: ShowHideTextButtonModel) => { + const label = useMemo(() => { + return show ? 'Hide Text' : 'Show Text'; + }, [show]); + + return textOverlay ? ( + <ShowHideTextButton + onPointerDown={onToggle} + theme={theme} + tabIndex={0} + onKeyDown={(ev) => ev.key === 'Enter' && onToggle?.(ev)} + aria-label={label} + title={label} + > + {show ? <MinusIcon /> : <PlusIcon />} + </ShowHideTextButton> + ) : null; + }, +); + +ShowOrHideTextButtonMemo.displayName = 'Show Hide Text Button'; + +const DetailsTextMemo = memo<DetailsTextMemoModel>( + ({ + theme, + show, + expand, + textOverlay, + text, + height, + onRender, + }: DetailsTextMemoModel) => { + const onTextRef = useCallback((node: HTMLDivElement) => { + if (node) { + onRender?.(node.clientHeight); + } + }, []); + + const Text = text; + + const background = useMemo(() => { + const bg = theme?.cardDetailsBackGround || ''; + if (bg) { + return hexToRGBA(bg, 0.8); + } else { + return bg; + } + }, [theme?.cardDetailsBackGround]); + + return textOverlay ? ( + <DetailsTextWrapper + ref={onTextRef} + // height={expand ? height : 0} + $expandFull={expand} + theme={theme} + $show={show} + background={background} + > + <Text /> + </DetailsTextWrapper> + ) : null; + }, + (prev, next) => + prev.height === next.height && + prev.show === next.show && + prev.expand === next.expand && + JSON.stringify(prev.theme) === JSON.stringify(next.theme), +); + +DetailsTextMemo.displayName = 'Details Text'; + +export { TitleMemo, SubTitleMemo, DetailsTextMemo }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { keyframes } from 'styled-components'; + +export const reveal = keyframes` + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } +`; + +export const slideInFromTop = keyframes` + 0% { + opacity: 0; + transform: translateY(-50%); + } + 100% { + opacity: 1; + transform: translateY(0); + } +`; + +export const slideInFromLeft = keyframes` + 0% { + opacity: 0; + transform: translateX(-50%); + } + 100% { + opacity: 1; + transform: translateX(0); + } +`; + +export const slideFromRight = keyframes` + 0% { + opacity: 0; + transform: translateX(50%); + } + 100% { + opacity: 1; + transform: translateX(0); + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +1x +1x +1x +1x +13x +13x +13x +13x +13x +13x +13x +13x +3x +3x +3x +3x + + + + +3x +3x +3x +3x +3x +3x +3x +3x +3x +10x +13x +13x +2x +2x +2x +2x +2x +2x +2x +2x +2x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +1x +1x + | import { TimelineMode } from '@models/TimelineModel'; +import { FunctionComponent, PointerEvent, useContext, useMemo } from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import ChevronIcon from '../../icons/chev-right'; +import { ContentFooterProps } from './header-footer.model'; +import { + ChevronIconWrapper, + ShowMore, + SlideShowProgressBar, + TriangleIconWrapper, +} from './timeline-card-content.styles'; + +/** + * ContentFooter + * + * A functional component that renders the footer of the timeline card. + * It displays the read more/less button, progress bar, and triangle icon. + * The read more/less button appears only if the content is large. + * The progress bar and triangle icon are displayed only if the card is in slideshow mode. + * + * @property {boolean} showProgressBar - Determines if progress bar should be displayed. + * @property {Function} onExpand - Function called when expanding content. + * @property {string} triangleDir - Direction of the triangle icon. + * @property {boolean} showMore - Determines if 'read more' should be displayed. + * @property {boolean} textContentIsLarge - Determines if text content is large. + * @property {boolean} showReadMore - Determines if 'read more' button should be displayed. + * @property {number} remainInterval - Remaining interval for progress bar. + * @property {boolean} paused - Determines if progress is paused. + * @property {number} startWidth - Starting width of progress bar. + * @property {boolean} canShow - Determines if the element can be shown. + * @property {React.RefObject} progressRef - Ref to the progress bar. + * @property {boolean} isNested - Determines if component is nested. + * @property {boolean} isResuming - Determines if slideshow is resuming. + * + * @returns {JSX.Element} ContentFooter component. + */ +const ContentFooter: FunctionComponent<ContentFooterProps> = ({ + showProgressBar, + onExpand, + triangleDir, + showMore, + textContentIsLarge, + showReadMore, + remainInterval, + paused, + startWidth, + canShow, + progressRef, + isNested, + isResuming, +}) => { + const { mode, theme } = useContext(GlobalContext); + + const canShowTriangleIcon = useMemo(() => { + return ( + !isNested && + (['VERTICAL', 'VERTICAL_ALTERNATING'] as TimelineMode[]).some( + (m) => m === mode, + ) + ); + }, [mode, isNested]); + + const handleClick = (ev: PointerEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + onExpand(); + }; + + const canShowMore = useMemo(() => { + return showReadMore && textContentIsLarge; + }, [showReadMore, textContentIsLarge]); + + return ( + <> + {canShowMore ? ( + <ShowMore + className="show-more" + onPointerDown={handleClick} + onKeyUp={(event) => { + if (event.key === 'Enter') { + onExpand(); + } + }} + show={canShow ? 'true' : 'false'} + theme={theme} + tabIndex={0} + > + {<span>{showMore ? 'read less' : 'read more'}</span>} + <ChevronIconWrapper collapsed={showMore ? 'true' : 'false'}> + <ChevronIcon /> + </ChevronIconWrapper> + </ShowMore> + ) : null} + + {showProgressBar && ( + <SlideShowProgressBar + color={theme?.primary} + $duration={remainInterval} + $paused={paused} + ref={progressRef} + $startWidth={startWidth} + role="progressbar" + $resuming={isResuming} + ></SlideShowProgressBar> + )} + + {canShowTriangleIcon && ( + <TriangleIconWrapper + dir={triangleDir} + theme={theme} + offset={-8} + ></TriangleIconWrapper> + )} + </> + ); +}; + +export { ContentFooter }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +9x +9x +9x +9x +9x +9x +9x +3x +3x +3x +3x +3x +3x +3x +9x +9x +9x +3x +3x +3x +3x +3x +3x +9x +9x +9x +9x +1x +1x +1x +1x +1x +1x + | import { FunctionComponent, memo, useContext } from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import { SubTitleMemo, TitleMemo } from '../memoized'; +import { ContentHeaderProps } from './header-footer.model'; +import { TimelineCardHeader } from './timeline-card-content.styles'; + +/** + * ContentHeader component + * This component renders the header of the timeline card including the title and subtitle. + * It doesn't render the title and subtitle if the card has media. + * The title and subtitle are memoized to prevent unnecessary re-renders. + * + * @property {string} title - The title of the card. + * @property {string} url - The URL of the card. + * @property {boolean} media - Indicates whether the card has media or not. + * @property {string} content - The main content of the card. + * @returns {JSX.Element} The ContentHeader component. + */ +const ContentHeader: FunctionComponent<ContentHeaderProps> = memo( + ({ title, url, media, content }: ContentHeaderProps) => { + // Using context to get global values + const { fontSizes, classNames, theme } = useContext(GlobalContext); + + return ( + <TimelineCardHeader> + {/* Render title if there is no media */} + {!media && ( + <TitleMemo + title={title} + theme={theme} + url={url} + fontSize={fontSizes?.cardTitle} + classString={classNames?.cardTitle} + /> + )} + {/* Render subtitle if there is no media */} + {!media && ( + <SubTitleMemo + content={content} + theme={theme} + fontSize={fontSizes?.cardSubtitle} + classString={classNames?.cardSubTitle} + /> + )} + </TimelineCardHeader> + ); + }, +); + +// Setting display name for easier debugging +ContentHeader.displayName = 'ContentHeader'; + +export { ContentHeader }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x + +8x +8x +8x +8x +8x +8x +8x +8x +8x +1x +1x +1x +1x +1x + | import { ReactNode, forwardRef, useContext } from 'react'; +import { TimelineContentDetailsWrapper } from './timeline-card-content.styles'; +import { GlobalContext } from '../../GlobalContext'; +import { TimelineContentModel } from '@models/TimelineContentModel'; +import { getTextOrContent } from './text-or-content'; + +type DetailsTextProps = Pick< + TimelineContentModel, + 'detailedText' | 'timelineContent' +> & { + cardActualHeight?: number; + contentDetailsClass?: string; + customContent?: ReactNode; + detailsHeight?: number; + gradientColor?: string; + showMore?: boolean; +}; + +const DetailsText = forwardRef<HTMLDivElement, DetailsTextProps>( + (prop, ref) => { + const { + showMore, + cardActualHeight, + detailsHeight, + gradientColor, + customContent, + timelineContent, + detailedText, + contentDetailsClass, + } = prop; + + const { + useReadMore, + borderLessCards, + contentDetailsHeight, + textOverlay, + theme, + } = useContext(GlobalContext); + + const TextContent = getTextOrContent({ + detailedText, + showMore, + theme, + timelineContent, + }); + + return ( + <> + {/* detailed text */} + <TimelineContentDetailsWrapper + aria-expanded={showMore} + className={contentDetailsClass} + $customContent={!!customContent} + ref={ref} + theme={theme} + $useReadMore={useReadMore} + $borderLess={borderLessCards} + $showMore={showMore} + $cardHeight={!textOverlay ? cardActualHeight : null} + $contentHeight={detailsHeight} + height={contentDetailsHeight} + $textOverlay={textOverlay} + $gradientColor={gradientColor} + > + {customContent ? ( + customContent + ) : ( + <TextContent + {...{ detailedText, showMore, theme, timelineContent }} + /> + )} + </TimelineContentDetailsWrapper> + </> + ); + }, +); + +DetailsText.displayName = 'Details Text'; + +export { DetailsText }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
card-animations.styles.ts | +
+
+ |
+ 100% | +45/45 | +100% | +0/0 | +100% | +0/0 | +100% | +45/45 | +
content-footer.tsx | +
+
+ |
+ 96.61% | +114/118 | +92.85% | +13/14 | +66.66% | +2/3 | +96.61% | +114/118 | +
content-header.tsx | +
+
+ |
+ 100% | +53/53 | +100% | +3/3 | +100% | +0/0 | +100% | +53/53 | +
details-text.tsx | +
+
+ |
+ 98.75% | +79/80 | +33.33% | +1/3 | +100% | +0/0 | +98.75% | +79/80 | +
text-or-content.tsx | +
+
+ |
+ 83.82% | +57/68 | +57.14% | +4/7 | +100% | +1/1 | +83.82% | +57/68 | +
timeline-card-content.styles.ts | +
+
+ |
+ 88.14% | +342/388 | +70.31% | +45/64 | +96.15% | +25/26 | +88.14% | +342/388 | +
timeline-card-content.tsx | +
+
+ |
+ 76.97% | +351/456 | +59.25% | +32/54 | +0% | +0/1 | +76.97% | +351/456 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +16x +16x +16x +16x +16x +16x +16x +8x +8x +8x +8x +8x +8x + +8x +8x +8x + + + + + + + + + + +8x +8x +8x +8x +8x +6x +6x +6x +6x +6x +6x +6x +2x +8x +8x +16x +16x +16x +16x +16x +16x +1x +1x + | import { TimelineContentModel } from '@models/TimelineContentModel'; +import { ForwardRefExoticComponent, forwardRef, useContext } from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import { + TimelineSubContent, + TimelineContentDetails, +} from './timeline-card-content.styles'; + +export type TextOrContentModel = Pick< + TimelineContentModel, + 'timelineContent' | 'theme' | 'detailedText' +> & { + showMore?: boolean; +}; + +const getTextOrContent: ( + p: TextOrContentModel, +) => ForwardRefExoticComponent<TextOrContentModel> = ({ + timelineContent, + theme, + detailedText, + showMore, +}) => { + const TextOrContent = forwardRef<HTMLParagraphElement, TextOrContentModel>( + (prop, ref) => { + // const { timelineContent, theme, detailedText, showMore } = prop; + const isTextArray = Array.isArray(detailedText); + + const { fontSizes, classNames } = useContext(GlobalContext); + + if (timelineContent) { + return <div ref={ref}>{timelineContent}</div>; + } else { + let textContent = null; + if (isTextArray) { + textContent = (detailedText as string[]).map((text, index) => ( + <TimelineSubContent + key={index} + fontSize={fontSizes?.cardText} + className={classNames?.cardText} + theme={theme} + > + {text} + </TimelineSubContent> + )); + } else { + textContent = detailedText; + } + + return textContent ? ( + <TimelineContentDetails + className={showMore ? 'active' : ''} + ref={ref} + theme={theme} + > + {textContent} + </TimelineContentDetails> + ) : null; + } + }, + ); + + TextOrContent.displayName = 'Text Or Content'; + + return TextOrContent; +}; + +export { getTextOrContent }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x +8x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x + + +8x +8x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x + + + + + + + + + + + + + + + + + + + + + + + + +8x +1x +1x +8x + + + + + + +8x +8x + + + + +8x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x +8x +1x +1x +8x +8x +8x +8x +8x +8x +8x + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x +8x +8x +8x +8x +8x +8x + + + + + + + + + +8x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +1x +1x +1x +1x +1x +2x +1x +1x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +3x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import { TimelineProps } from '@models/TimelineModel'; +import styled, { css, keyframes } from 'styled-components'; +import { linearGradient } from '../timeline-card-media/timeline-card-media.styles'; +import { + reveal, + slideFromRight, + slideInFromLeft, + slideInFromTop, +} from './card-animations.styles'; + +type ContentT = Pick< + TimelineProps, + 'theme' | 'slideShow' | 'mode' | 'borderLessCards' +>; + +export const TimelineItemContentWrapper = styled.section< + { + $active?: boolean; + $borderLessCards?: TimelineProps['borderLessCards']; + $branchDir?: string; + $isNested?: boolean; + $maxWidth?: number; + $minHeight?: number; + $noMedia?: boolean; + $slideShow?: TimelineProps['slideShow']; + $slideShowActive?: boolean; + $slideShowType?: TimelineProps['slideShowType']; + $textOverlay?: boolean; + } & ContentT +>` + align-items: flex-start; + background: ${(p) => p.theme.cardBgColor}; + border-radius: 4px; + display: flex; + position: absolute; + ${({ borderLessCards }) => + !borderLessCards + ? `filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.3))` + : 'none'}; + flex-direction: column; + justify-content: flex-start; + line-height: 1.5em; + margin: ${(p) => (p.mode === 'HORIZONTAL' ? '0 auto' : '')}; + max-width: ${(p) => p.$maxWidth}px; + min-height: ${(p) => p.$minHeight}px; + position: relative; + text-align: left; + width: 98%; + z-index: 0; + + ${(p) => + p.$isNested + ? css` + background: ${p.theme.nestedCardBgColor}; + box-shadow: 0 0 5px 2px rgba(0, 0, 0, 0.1); + ` + : css``} + + height: ${(p) => (p.$textOverlay ? '0' : '')}; + + &:focus { + outline: 1px solid ${(p) => p.theme?.primary}; + } + + ${(p) => { + if (p.$slideShowActive && p.$active) { + if (p.$slideShowType === 'slide_in') { + return css` + animation: ${slideInFromTop} 0.5s ease-in-out; + `; + } else if ( + p.$slideShowType === 'slide_from_sides' && + p.$branchDir === 'left' + ) { + return css` + animation: ${slideInFromLeft} 0.5s ease-in-out; + `; + } else if ( + p.$slideShowType === 'slide_from_sides' && + p.$branchDir === 'right' + ) { + return css` + animation: ${slideFromRight} 0.5s ease-in-out; + `; + } else { + return css` + animation: ${reveal} 0.5s ease-in-out; + `; + } + } + }} + + ${(p) => { + if (p.$slideShowActive && p.$active) { + return css` + opacity: 1; + animation-timing-function: ease-in-out; + animation-duration: 0.5s; + `; + } + + if (p.$slideShowActive && !p.$active) { + return css` + opacity: 0; + `; + } + }} +`; + +export const TimelineCardHeader = styled.header` + width: 100%; + padding: 0.5rem 0.5rem 0 0.5rem; +`; + +export const CardSubTitle = styled.h2<{ + $fontSize?: string; + $padding?: boolean; + dir?: string; + theme?: Theme; +}>` + color: ${(p) => p.theme.cardSubtitleColor}; + font-size: ${(p) => p.$fontSize}; + font-weight: 600; + margin: 0; + text-align: left; + width: 97%; + padding: ${(p) => (p.$padding ? '0.5rem 0 0.5rem 0.5rem;' : '')}; +`; + +export const CardTitle = styled.h1<{ + $fontSize: string; + $padding?: boolean; + dir?: string; + theme: Theme; +}>` + color: ${(p) => p.theme.cardTitleColor}; + font-size: ${(p) => p.$fontSize}; + font-weight: 600; + margin: 0; + text-align: left; + width: 95%; + padding: ${(p) => (p.$padding ? '0.25rem 0 0.25rem 0.5rem;' : '')} &.active { + color: ${(p) => p.theme.primary}; + } +`; + +export const CardTitleAnchor = styled.a` + color: inherit; + + &:active { + color: inherit; + } +`; + +export const TimelineContentDetails = styled.p<{ theme?: Theme }>` + font-size: 0.85rem; + font-weight: 400; + margin: 0; + width: 100%; + color: ${(p) => p.theme.cardDetailsColor}; +`; + +export const TimelineSubContent = styled.span<{ + fontSize?: string; + theme?: Theme; +}>` + margin-bottom: 0.5rem; + display: block; + font-size: ${(p) => p.fontSize}; + color: ${(p) => p.theme.cardDetailsColor}; +`; + +export const TimelineContentDetailsWrapper = styled.div<{ + $borderLess?: boolean; + $cardHeight?: number | null; + $contentHeight?: number; + $customContent?: boolean; + $gradientColor?: string | null; + $showMore?: boolean; + $textOverlay?: boolean; + $useReadMore?: boolean; + branchDir?: string; + height?: number; + theme?: Theme; +}>` + align-items: center; + display: flex; + flex-direction: column; + margin: 0 auto; + margin-top: 0.5em; + margin-bottom: 0.5em; + position: relative; + ${({ $useReadMore, $customContent, $showMore, height = 0, $textOverlay }) => + $useReadMore && !$customContent && !$showMore && !$textOverlay + ? `max-height: ${height}px;` + : ''} + ${({ + $cardHeight = 0, + $contentHeight = 0, + height = 0, + $showMore, + $textOverlay, + }) => + $showMore && !$textOverlay + ? `max-height: ${($cardHeight || 0) + ($contentHeight || 0) - height}px;` + : ''} + overflow-x: hidden; + overflow-y: auto; + scrollbar-color: ${(p) => p.theme?.primary} default; + scrollbar-width: thin; + transition: max-height 0.25s ease-in-out; + width: ${(p) => + p.$borderLess ? 'calc(100% - 0.5rem)' : 'calc(95% - 0.5rem)'}; + padding: 0.25rem 0.25rem; + + $${({ + height = 0, + $cardHeight = 0, + $contentHeight = 0, + $showMore, + $useReadMore, + }) => + $showMore && $useReadMore && $cardHeight + ? css` + animation: ${keyframes` + 0% { + max-height: ${height}px; + } + 100% { + max-height: ${$cardHeight + $contentHeight - height}px; + } + `} 0.25s ease-in-out; + ` + : ''} + &::-webkit-scrollbar { + width: 0.3em; + } + + &::-webkit-scrollbar-track { + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); + } + + &::-webkit-scrollbar-thumb { + background-color: ${(p) => p.theme?.primary}; + outline: 1px solid ${(p) => p.theme?.primary}; + } + + &.show-less { + scrollbar-width: none; + + &::-webkit-scrollbar { + width: 0; + } + overflow: hidden; + } + + --rc-gradient-color: ${(p) => p.$gradientColor}; + ${linearGradient} +`; + +export const ShowMore = styled.button<{ + show?: 'true' | 'false'; + theme?: Theme; +}>` + align-items: center; + align-self: flex-end; + border-radius: 4px; + cursor: pointer; + display: ${(p) => (p.show === 'true' ? 'flex' : 'none')}; + font-size: 0.75rem; + justify-self: flex-end; + margin-bottom: 0.5em; + margin-left: 0.5em; + margin-right: 0.5em; + margin-top: auto; + padding: 0.25em; + color: ${(p) => p.theme.primary}; + border: 0; + background: none; + + &:hover { + text-decoration: underline; + } +`; + +const slideAnimation = (start?: number, end?: number) => keyframes` + 0% { + width: ${start}px; + } + 100% { + width: ${end}px; + } +`; + +export const SlideShowProgressBar = styled.span<{ + $color?: string; + $duration?: number; + $paused?: boolean; + $resuming?: boolean; + $startWidth?: number; +}>` + background: ${(p) => p.color}; + bottom: -0.75em; + display: block; + height: 4px; + left: 50%; + transform: translateX(-50%); + position: absolute; + border-radius: 2px; + + ${(p) => { + if (p.$paused) { + return css` + left: 50%; + transform: translateX(-50%); + `; + } + }} + + ${(p) => { + if (!p.$paused && p.$startWidth && p.$startWidth > 0) { + return css` + animation: ${slideAnimation(p.$startWidth, 0)} ${p.$duration}ms ease-in; + animation-play-state: running; + `; + } else { + return css` + animation-play-state: paused; + width: ${p.$startWidth}px; + `; + } + }} + + svg { + position: absolute; + left: 0; + top: 0; + width: 100%; + } +`; + +export const ChevronIconWrapper = styled.span<{ collapsed?: 'true' | 'false' }>` + align-items: center; + display: flex; + height: 1.25em; + justify-content: center; + margin-left: 0.2em; + margin-top: 0.2em; + width: 1.25em; + ${(p) => + p.collapsed === 'false' + ? ` + transform: rotate(90deg); + ` + : `transform: rotate(-90deg)`}; + + svg { + height: 100%; + width: 100%; + } +`; + +export const TriangleIconWrapper = styled.span<{ + dir?: string; + offset?: number; + theme?: Theme; +}>` + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + position: absolute; + top: calc(50%); + background: ${(p) => p.theme.cardBgColor}; + transform: translateY(-50%) rotate(225deg); + z-index: -1; + + & svg { + width: 100%; + height: 100%; + fill: #fff; + } + + ${(p) => + p.dir === 'left' ? `right: ${p.offset}px;` : `left: ${p.offset}px;`}; +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +8x +20x +20x +20x +20x +20x +20x +8x +20x +20x +20x +8x +8x +8x +8x +8x +20x +20x +20x +8x +6x +6x +20x +20x +20x +20x +16x +8x +8x +8x +16x + + +8x +8x +8x +8x +8x +8x +16x +20x +20x +20x +20x + + + + + + + + + + + + + + + + + + +20x +20x +20x +8x + + +20x +20x +20x +20x + + + + + + + + + + + + + +20x +20x +20x +20x + + + + + + + + + + + + + +20x +20x +20x +8x + + +8x +8x + + + +8x +8x +8x + + +8x +8x +8x +8x +20x +20x +20x +8x + + +20x +20x +20x +8x + + +20x +20x +20x +8x +20x +20x +20x +20x +20x +20x +8x +20x +20x +20x +20x +20x +20x +20x +20x + + + + + + + + + + + +20x +20x +20x +20x +20x +8x +8x +8x +8x +20x +20x +20x +20x +20x +8x +8x +8x + +8x +8x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +8x + +8x +8x +8x + + +20x +20x +20x + + + + +20x +20x +20x +8x + + + + + + +8x +20x +20x +20x +20x +20x +20x +20x +20x +8x +8x + +8x + +8x +20x +20x +20x +20x +8x +20x +20x +20x +8x +8x +8x +8x +8x +8x +20x +20x +20x +8x +8x +8x + + + + + + + + + + +8x +8x +8x +8x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +18x +18x +18x +18x +18x +18x +18x +2x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x + + + + + + + + + +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +1x +1x +1x +1x +1x + | import { TimelineContentModel } from '@models/TimelineContentModel'; +import { MediaState } from '@models/TimelineMediaModel'; +import { hexToRGBA } from '@utils/index'; +import cls from 'classnames'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import Timeline from '../../timeline/timeline'; +import CardMedia from '../timeline-card-media/timeline-card-media'; +import { ContentFooter } from './content-footer'; +import { ContentHeader } from './content-header'; +import { TimelineItemContentWrapper } from './timeline-card-content.styles'; +import { getTextOrContent } from './text-or-content'; +import { DetailsText } from './details-text'; + +const TimelineCardContent: React.FunctionComponent<TimelineContentModel> = + React.memo( + ({ + active, + content, + detailedText, + id, + media, + onShowMore, + slideShowActive, + onElapsed, + theme, + title, + onClick, + customContent, + hasFocus, + flip, + branchDir, + url, + timelineContent, + items, + isNested, + nestedCardHeight, + }: TimelineContentModel) => { + const [showMore, setShowMore] = useState(false); + const detailsRef = useRef<HTMLDivElement | null>(null); + const containerRef = useRef<HTMLDivElement | null>(null); + const progressRef = useRef<HTMLDivElement | null>(null); + + const containerWidth = useRef<number>(0); + const slideShowElapsed = useRef(0); + const timerRef = useRef(0); + const startTime = useRef<Date>(); + const [paused, setPaused] = useState(false); + const isFirstRender = useRef(true); + + const [remainInterval, setRemainInterval] = useState(0); + const [startWidth, setStartWidth] = useState(0); + const [textContentLarge, setTextContentLarge] = useState(false); + + const [cardActualHeight, setCardActualHeight] = useState(0); + const [detailsHeight, setDetailsHeight] = useState(0); + const [hasBeenActivated, setHasBeenActivated] = useState(false); + const [isResuming, setIsResuming] = useState(false); + + const { + mode, + cardHeight, + slideItemDuration = 2000, + useReadMore, + cardWidth, + borderLessCards, + disableAutoScrollOnClick, + classNames, + textOverlay, + slideShowType, + showProgressOnSlideshow, + } = useContext(GlobalContext); + + // If the media is a video, we don't show the progress bar. + // If the media is an image, we show the progress bar if the + // showProgressOnSlideshow flag is set. + const canShowProgressBar = useMemo(() => { + return active && slideShowActive && showProgressOnSlideshow; + }, [active, slideShowActive]); + + // This function returns a boolean value that indicates whether the user + // can see more information about the item. The detailed text is only + // available if the user has expanded the row. + const canShowMore = useMemo(() => { + return !!detailedText; + }, [detailedText]); + + useEffect(() => { + const detailsEle = detailsRef.current; + + if (detailsEle) { + detailsEle.scrollTop = 0; + } + }, [showMore]); + + useEffect(() => { + if (active) { + setHasBeenActivated(true); + } + }, [active]); + + const onContainerRef = useCallback( + (node: HTMLElement) => { + if (node === null) { + return; + } + const detailsEle = detailsRef.current; + if (!detailsEle) { + return; + } + const { scrollHeight, offsetTop } = detailsEle; + containerWidth.current = node.clientWidth; + setStartWidth(containerWidth.current); + setCardActualHeight(scrollHeight); + setDetailsHeight(detailsEle.offsetHeight); + setTextContentLarge(scrollHeight + offsetTop > node.clientHeight); + }, + [detailsRef.current], + ); + + const setupTimer = useCallback((interval: number) => { + if (!slideItemDuration) { + return; + } + + setRemainInterval(interval); + + startTime.current = new Date(); + + setPaused(false); + + timerRef.current = window.setTimeout(() => { + // clear the timer and move to the next card + window.clearTimeout(timerRef.current); + setPaused(true); + setStartWidth(0); + setRemainInterval(slideItemDuration); + id && onElapsed && onElapsed(id); + }, interval); + }, []); + + useEffect(() => { + if (timerRef.current && !slideShowActive) { + window.clearTimeout(timerRef.current); + } + }, [slideShowActive]); + + // pause the slide show + const tryHandlePauseSlideshow = useCallback(() => { + if (active && slideShowActive) { + window.clearTimeout(timerRef.current); + setPaused(true); + + if (startTime.current) { + const elapsed: any = +new Date() - +startTime.current; + slideShowElapsed.current = elapsed; + } + + if (progressRef.current) { + setStartWidth(progressRef.current.clientWidth); + } + } + }, [active, slideShowActive]); + + // resumes the slide show + const tryHandleResumeSlideshow = useCallback(() => { + if (active && slideShowActive) { + if (!slideItemDuration) { + return; + } + const remainingInterval = + slideItemDuration - slideShowElapsed.current; + + setPaused(false); + + if (remainingInterval > 0) { + setupTimer(remainingInterval); + } + } + }, [active, slideShowActive, slideItemDuration]); + + useEffect(() => { + if (!slideItemDuration) { + return; + } + // setup the timer + if (active && slideShowActive) { + setStartWidth(containerWidth.current); + setupTimer(slideItemDuration); + } + + // disabled autofocus on active + if (active && hasFocus) { + containerRef.current && containerRef.current.focus(); + } + + if (!slideShowActive) { + setHasBeenActivated(false); + } + }, [active, slideShowActive]); + + useEffect(() => { + if (hasFocus && active) { + containerRef.current && containerRef.current.focus(); + } + }, [hasFocus, active]); + + useEffect(() => { + if (!paused && !isFirstRender.current) { + setIsResuming(true); + } + }, [paused, startWidth]); + + useEffect(() => { + isFirstRender.current = false; + }, []); + + // This code is used to determine whether the read more button should be shown. + // It is only shown if the useReadMore prop is true, the detailedText is non-null, + // and the customContent prop is false. + const canShowReadMore = useMemo(() => { + return useReadMore && detailedText && !customContent; + }, []); + + // decorate the comments + // This function is triggered when the media state changes. If the slideshow is + // active, and the media state changes to paused, this function will call + // tryHandlePauseSlideshow(), which will pause the slideshow. + const handleMediaState = useCallback( + (state: MediaState) => { + if (!slideShowActive) { + return; + } + if (state.playing) { + tryHandlePauseSlideshow(); + } else if (state.paused) { + if (paused && id && onElapsed) { + onElapsed(id); + } + } + }, + [paused, slideShowActive], + ); + + const contentClass = useMemo( + () => + cls( + active ? 'timeline-card-content active' : 'timeline-card-content ', + classNames?.card, + ), + [active], + ); + + const contentDetailsClass = useMemo( + () => + cls( + !showMore && !customContent && useReadMore + ? 'show-less card-description' + : 'card-description', + classNames?.cardText, + ), + [showMore, customContent], + ); + + /** + * Calculate the minimum height of the card. If the card has a text overlay and + * media, the minimum height is equal to the card height. If the card is not + * nested, the minimum height is equal to the card height. If the card is nested, + * the minimum height is equal to the nested card height. + */ + const cardMinHeight = useMemo(() => { + if (textOverlay && media) { + return cardHeight; + } else if (!isNested) { + return cardHeight; + } else { + return nestedCardHeight; + } + }, []); + + const handleExpandDetails = useCallback(() => { + if ((active && paused) || !slideShowActive) { + setShowMore(!showMore); + onShowMore(); + } + }, [active, paused, slideShowActive, showMore]); + + const triangleDir = useMemo(() => { + if (flip) { + if (branchDir === 'right') { + return 'left'; + } else { + return 'right'; + } + } + return branchDir; + }, [branchDir, flip]); + + // Get the background color for the gradient, which is either the + // cardDetailsBackGround or nestedCardDetailsBackGround theme variable, + // based on whether the card is nested or not. If we are showing more + // content, the background color should be null, so that there is no + // gradient. + const gradientColor = useMemo(() => { + const bgToUse = !isNested + ? theme?.cardBgColor + : theme?.nestedCardDetailsBackGround; + return !showMore && textContentLarge + ? hexToRGBA(bgToUse || '#ffffff', 0.8) + : null; + }, [textContentLarge, showMore, theme?.cardDetailsBackGround, isNested]); + + // This code checks whether the textOverlay and items props are truthy. If so, then it returns false. Otherwise, it returns true. + const canShowDetailsText = useMemo(() => { + return !textOverlay && !items?.length; + }, [items?.length]); + + const TextOrContent = useMemo(() => { + return getTextOrContent({ + detailedText, + showMore, + theme, + timelineContent, + }); + }, [showMore, timelineContent, theme, detailedText]); + + const handlers = useMemo(() => { + if (!isNested) { + return { + onPointerDown: (ev: React.PointerEvent) => { + ev.stopPropagation(); + if ( + !slideShowActive && + onClick && + id && + !disableAutoScrollOnClick + ) { + onClick(id); + } + }, + onPointerEnter: tryHandlePauseSlideshow, + onPointerLeave: tryHandleResumeSlideshow, + }; + } + }, [tryHandlePauseSlideshow, tryHandleResumeSlideshow]); + + return ( + <TimelineItemContentWrapper + className={contentClass} + $minHeight={cardMinHeight} + $maxWidth={cardWidth} + mode={mode} + $noMedia={!media} + {...handlers} + ref={onContainerRef} + tabIndex={!isNested ? 0 : -1} + theme={theme} + $borderLessCards={borderLessCards} + $textOverlay={textOverlay} + $active={hasBeenActivated} + $slideShowType={slideShowType} + $slideShowActive={slideShowActive} + $branchDir={branchDir} + $isNested={isNested} + > + {title && !textOverlay ? ( + <ContentHeader + title={title} + theme={theme} + url={url} + media={media} + content={content} + /> + ) : null} + + {/* render media video or image */} + {media && ( + <CardMedia + active={active} + cardHeight={cardHeight} + content={content} + hideMedia={showMore} + id={id} + media={media} + onMediaStateChange={handleMediaState} + slideshowActive={slideShowActive} + theme={theme} + title={title} + url={url} + startWidth={startWidth} + detailsText={TextOrContent} + paused={paused} + remainInterval={remainInterval} + showProgressBar={canShowProgressBar} + triangleDir={triangleDir} + resuming={isResuming} + progressRef={progressRef} + /> + )} + + {canShowDetailsText ? ( + <DetailsText + showMore={showMore} + gradientColor={gradientColor} + detailedText={detailedText} + customContent={customContent} + timelineContent={timelineContent} + contentDetailsClass={contentDetailsClass} + cardActualHeight={cardActualHeight} + detailsHeight={detailsHeight} + ref={detailsRef} + /> + ) : ( + <Timeline + items={items} + mode={'VERTICAL'} + enableOutline={false} + hideControls + nestedCardHeight={nestedCardHeight} + isChild + /> + )} + + {(!textOverlay || !media) && ( + <ContentFooter + theme={theme} + progressRef={progressRef} + startWidth={startWidth} + textContentIsLarge={textContentLarge} + remainInterval={remainInterval} + paused={paused} + triangleDir={triangleDir} + showProgressBar={canShowProgressBar} + showReadMore={canShowReadMore} + onExpand={handleExpandDetails} + canShow={canShowMore} + showMore={showMore} + isNested={isNested} + isResuming={isResuming} + /> + )} + </TimelineItemContentWrapper> + ); + }, + ); + +TimelineCardContent.displayName = 'TimelineCardContent'; + +export default TimelineCardContent; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-card-media-buttons.tsx | +
+
+ |
+ 100% | +48/48 | +100% | +1/1 | +100% | +0/0 | +100% | +48/48 | +
timeline-card-media.styles.ts | +
+
+ |
+ 92.43% | +220/238 | +80% | +44/55 | +100% | +17/17 | +92.43% | +220/238 | +
timeline-card-media.tsx | +
+
+ |
+ 86.82% | +356/410 | +73.46% | +36/49 | +25% | +1/4 | +86.82% | +356/410 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import styled, { css } from 'styled-components'; + +const Button = css` + align-items: center; + background: none; + // background: rgba(0, 0, 0, 0.1); + border-radius: 50%; + border: none; + cursor: pointer; + display: flex; + height: 1.5rem; + justify-content: center; + padding: 0; + width: 1.5rem; + margin: 0 0.25rem; + background: ${(p) => p.theme?.primary}; + color: #fff; + + svg { + width: 70%; + height: 70%; + } +`; + +export const ExpandButton = styled.button<{ + // expandFull?: boolean; + theme: Theme; +}>` + ${Button} +`; + +export const ShowHideTextButton = styled.button<{ + showText?: boolean; + theme: Theme; +}>` + ${Button} +`; + +export const ButtonWrapper = styled.ul` + display: flex; + flex-direction: row; + justify-content: flex-end; + list-style: none; + margin: 0; + padding: 0; + margin-left: auto; +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +16x +1x +1x +1x +16x +15x + + + +15x +15x +15x +15x +15x +15x +16x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +16x +16x +16x +16x +16x +16x +16x +1x +1x +1x +1x +1x +16x + + + + + + +16x +16x + + + + + + +16x +16x +2x +2x +2x +2x +2x +2x +16x +1x +1x +16x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +14x +14x +1x +1x +1x +16x + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +3x +1x +1x +1x +3x +2x +2x +2x +2x +3x +1x +1x +3x +3x + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import { TimelineMode } from '@models/TimelineModel'; +import styled, { css } from 'styled-components'; +import { ScrollBar } from '../../common/styles'; + +export const linearGradient = css` + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2rem; + background: linear-gradient( + 0deg, + var(--rc-gradient-color) 0%, + rgba(255, 255, 255, 0) 100% + ); + } +`; + +export const MediaWrapper = styled.div<{ + $active?: boolean; + $cardHeight?: number; + $slideShowActive?: boolean; + $textOverlay?: boolean; + align?: 'left' | 'right' | 'center'; + dir?: string; + mode?: TimelineMode; + theme?: Theme; +}>` + align-items: flex-start; + align-self: center; + background: ${(p) => (!p.$textOverlay ? p.theme?.cardMediaBgColor : 'none')}; + border-radius: 4px; + flex-direction: row; + height: ${(p) => (p.$textOverlay ? 'calc(100% - 1em)' : '0')}; + padding: 0.5em; + // pointer-events: ${(p) => (!p.$active && p.$slideShowActive ? 'none' : '')}; + position: relative; + text-align: ${(p) => p.align}; + width: calc(100% - 1em); + + ${(p) => (p.$cardHeight ? `min-height: ${p.$cardHeight}px;` : '')}; + ${(p) => { + if (p.mode === 'HORIZONTAL') { + return ` + justify-content: flex-start; + `; + } else { + if (p.dir === 'left') { + return ` + justify-content: flex-start; + `; + } else { + return ` + justify-content: flex-end; + `; + } + } + }} +`; + +export const CardImage = styled.img<{ + $enableBorderRadius?: boolean; + $visible?: boolean; + dir?: string; + fit?: string; + mode?: TimelineMode; +}>` + flex: 4; + justify-self: center; + margin-left: auto; + margin-right: auto; + height: 100%; + width: 100%; + object-fit: ${(p) => p.fit || 'cover'}; + object-position: center; + visibility: ${(p) => (p.$visible ? 'visible' : 'hidden')}; + border-radius: ${(p) => (p.$enableBorderRadius ? '6px' : '0')}; +`; + +export const CardVideo = styled.video<{ height?: number }>` + max-width: 100%; + max-height: 100%; + margin-left: auto; + margin-right: auto; +`; + +export const MediaDetailsWrapper = styled.div<{ + $absolutePosition?: boolean; + $borderLessCard?: boolean; + $expandFull?: boolean; + $expandable?: boolean; + $gradientColor?: string | null; + $showText?: boolean; + $textInMedia?: boolean; + mode?: TimelineMode; + theme?: Theme; +}>` + bottom: 0; + left: 0; + right: 0; + margin-right: auto; + width: ${(p) => { + switch (p.mode) { + case 'HORIZONTAL': + case 'VERTICAL': + case 'VERTICAL_ALTERNATING': + return `calc(90% - 0rem)`; + } + }}; + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + ${(p) => { + if (p.$textInMedia && p.$expandFull) { + return css` + height: 100%; + width: 100%; + border: 0; + `; + } + + if (!p.$showText) { + return css` + height: 15%; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); + border-radius: 10px; + `; + } + + if (p.$textInMedia && p.$expandable) { + return css` + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); + border-radius: 10px; + height: 50%; + `; + } + }} + position: ${(p) => (p.$absolutePosition ? 'absolute' : 'relative')}; + ${(p) => + p.$absolutePosition + ? ` + left: 50%; + bottom: ${p.$expandFull ? '0%' : ' 5%'}; + transform: translateX(-50%); + background: ${ + p.$showText ? p.theme?.cardDetailsBackGround : p.theme?.cardBgColor + }; + // backdrop-filter: blur(1px); + padding: 0.25rem; + ${p.$showText ? `overflow: auto;` : `overflow: hidden;`} + transition: height 0.25s ease-out, width 0.25s ease-out, bottom 0.25s ease-out, background 0.25s ease-out; + ` + : ``} + + ${({ $borderLessCard }) => + $borderLessCard + ? `border-radius: 6px; box-shadow: 0 0 10px 0 rgba(0,0,0,0.2);` + : ``} + --rc-gradient-color: ${(p) => p.$gradientColor}; + ${(p) => (p.$gradientColor ? linearGradient : null)} +`; + +export const ErrorMessage = styled.span` + color: #a3a3a3; + left: 50%; + position: absolute; + text-align: center; + top: 50%; + transform: translateY(-50%) translateX(-50%); +`; + +export const IFrameVideo = styled.iframe` + position: relative; + height: 100%; + width: 100%; +`; + +export const DetailsTextWrapper = styled.div<{ + $expandFull?: boolean; + $show?: boolean; + background: string; + theme?: Theme; +}>` + align-self: center; + display: flex; + transition: height 0.5s ease; + width: calc(100%); + background: ${(p) => p.background}; + color: ${(p) => p.theme?.cardDetailsColor}; + padding: 0.5rem; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + position: relative; + align-items: flex-start; + justify-content: center; + + ${ScrollBar} + + ${(p) => { + if (p.$expandFull) { + return ` + overflow: auto; + `; + } else { + return ` + overflow: hidden; + `; + } + }} + + ${(p) => + p.$show + ? ` + height: 100%;` + : ` + height: 0; + `} + + ${(p) => !p.$expandFull && linearGradient} +`; + +export const CardMediaHeader = styled.div` + padding: 0.5rem 0 0.5rem 0.5rem; + display: flex; + align-items: center; + justify-content: center; +`; + +export const ImageWrapper = styled.div<{ height?: number }>` + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 6px; +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x + + +16x +16x +7x +7x +16x +9x +9x +9x +16x +16x +16x +16x + +16x +16x +16x +16x + + + + + + + + +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x + + + + + +16x +16x + + + + + +16x +16x + + + + + +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +2x + + +16x +16x +16x +16x +16x + + + + + +16x +16x +16x +16x +16x +16x +16x +16x + + + + + +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +2x +16x +14x +14x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +14x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x + +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +16x +2x + + + + +16x +16x +16x +14x +14x + + +16x +16x +16x + + + + + + + + + +16x +16x +16x +2x +2x +2x +2x +2x +2x +2x +14x +16x +16x +16x +16x +16x +1x +1x +1x +1x + | import { CardMediaModel } from '@models/TimelineMediaModel'; +import { hexToRGBA } from '@utils/index'; +import cls from 'classnames'; +import React, { + FunctionComponent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import { + DetailsTextMemo, + ExpandButtonMemo, + ShowOrHideTextButtonMemo, + SubTitleMemo, + TitleMemo, +} from '../memoized'; +import { + SlideShowProgressBar, + TriangleIconWrapper, +} from '../timeline-card-content/timeline-card-content.styles'; +import { ButtonWrapper } from './timeline-card-media-buttons'; +import { + CardImage, + CardMediaHeader, + CardVideo, + ErrorMessage, + IFrameVideo, + ImageWrapper, + MediaDetailsWrapper, + MediaWrapper, +} from './timeline-card-media.styles'; + +interface ErrorMessageModel { + message: string; +} + +const CardMedia: React.FunctionComponent<CardMediaModel> = ({ + active, + id, + onMediaStateChange, + title, + content, + media, + slideshowActive, + url, + detailsText, + showProgressBar, + remainInterval, + startWidth, + paused, + triangleDir, + resuming, + progressRef, +}: CardMediaModel) => { + const videoRef = useRef<HTMLVideoElement>(null); + const [loadFailed, setLoadFailed] = useState(false); + const moreRef = useRef(null); + const [detailsHeight, setDetailsHeight] = useState(0); + const [expandDetails, setExpandDetails] = useState(false); + const [showText, setShowText] = useState(true); + const [mediaLoaded, setMediaLoaded] = useState(false); + + const { + mode, + fontSizes, + classNames, + mediaHeight, + borderLessCards, + textOverlay, + theme, + cardHeight, + mediaSettings, + } = useContext(GlobalContext); + + useEffect(() => { + if (!videoRef) { + return; + } + + if (active) { + // play the video when active + videoRef.current && videoRef.current.play(); + } else { + // pause the video when not active + videoRef.current && videoRef.current.pause(); + } + }, [active]); + + // This function will be invoked when the user has finished loading media + const handleMediaLoaded = useCallback(() => { + setMediaLoaded(true); + }, []); + + // This code creates a function to handle errors when loading the video. + const handleError = useCallback(() => { + // create a function to handle errors + setLoadFailed(true); // set the loadFailed variable to true + onMediaStateChange({ + // call the onMediaStateChange function + id, + paused: false, + playing: false, + }); + }, [onMediaStateChange, id]); // add the onMediaStateChange and id variables as dependencies to the function + + const ErrorMessageMem: FunctionComponent<ErrorMessageModel> = memo( + ({ message }: ErrorMessageModel) => <ErrorMessage>{message}</ErrorMessage>, + ); + + // Checks if the media source url is a YouTube video. + // Returns a boolean. + const isYouTube = useMemo( + () => + /^(https?\:\/\/)?(www\.youtube\.com|youtu\.?be)\/.+$/.test( + media.source.url, + ), + [], + ); + + /** + * @function IFrameVideo + * @description + * The IFrameVideo component is used to display an iframe with a YouTube video. + * @returns {IFrameVideo} - Returns the iframe with the YouTube video. + */ + const IFrameYouTube = useMemo(() => { + // Create an iframe with the YouTube video + const iframe = ( + <IFrameVideo + frameBorder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + allowFullScreen + src={`${media.source.url}${ + active ? '?autoplay=1&enablejsapi=1' : '?enablejsapi=1' + }`} + title={media.name} + /> + ); + + // When the YouTube video is active, return the iframe + return iframe; + }, [active]); + + const Video = useMemo(() => { + return ( + <CardVideo + controls + autoPlay={active} + ref={videoRef} + onLoadedData={handleMediaLoaded} + data-testid="rc-video" + onPlay={() => + onMediaStateChange({ + id, + paused: false, + playing: true, + }) + } + onPause={() => + onMediaStateChange({ + id, + paused: true, + playing: false, + }) + } + onEnded={() => + onMediaStateChange({ + id, + paused: false, + playing: false, + }) + } + onError={handleError} + > + <source src={media.source.url}></source> + </CardVideo> + ); + }, [active]); + + const Image = useMemo(() => { + return ( + <CardImage + src={media.source.url} + mode={mode} + onLoad={handleMediaLoaded} + onError={handleError} + $visible={mediaLoaded} + alt={media.name} + loading={'lazy'} + $enableBorderRadius={borderLessCards} + role="img" + fit={mediaSettings?.imageFit} + /> + ); + }, [mediaLoaded, borderLessCards]); + + ErrorMessageMem.displayName = 'Error Message'; + + // This code calculates the height of the Details component and passes it to + // the setDetailsHeight function. + const onDetailsTextRef = useCallback((height?: number) => { + if (height) { + setDetailsHeight(height); + } + }, []); + + /* Toggle the expand details state on pointer or keyboard event */ + const toggleExpand = useCallback( + (ev: React.PointerEvent | React.KeyboardEvent) => { + // ev.preventDefault(); + // ev.stopPropagation(); + setExpandDetails((prev) => !prev); + setShowText(true); + }, + [], + ); + + // This function is used to toggle the text between hidden and visible. + // It is used to show the text of the article excerpt when the user + // clicks on the "show more" button. + const toggleText = useCallback( + (ev: React.PointerEvent | React.KeyboardEvent) => { + // ev.preventDefault(); + // ev.stopPropagation(); + setExpandDetails(false); + setShowText((prev) => !prev); + }, + [], + ); + + // checks if the arrow should be shown + const canShowArrow = useMemo( + () => + (mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING') && textOverlay, + [], + ); + + // checks if we can display the detailed text + const canShowTextMemo = useMemo( + () => showText && !!detailsText, + [showText, detailsText], + ); + + // checks if the text content should be shown + const canShowTextContent = useMemo( + () => title || content || detailsText, + [title, content, detailsText], + ); + + const canExpand = useMemo( + () => textOverlay && !!detailsText, + [content, detailsText], + ); + + const gradientColor = useMemo( + () => hexToRGBA(theme?.cardDetailsBackGround || '', 0.8), + [theme?.cardDetailsBackGround], + ); + + const canShowGradient = useMemo( + () => !expandDetails && showText && textOverlay, + [expandDetails, showText], + ); + + const getCardHeight = useMemo(() => { + if (textOverlay) { + return cardHeight; + } else { + return mediaHeight; + } + }, []); + + const TextContent = useMemo(() => { + return ( + <MediaDetailsWrapper + mode={mode} + $absolutePosition={textOverlay} + $textInMedia={textOverlay} + ref={moreRef} + theme={theme} + $expandFull={expandDetails} + $showText={showText} + $expandable={canExpand} + $gradientColor={canShowGradient ? gradientColor : null} + > + <CardMediaHeader> + <TitleMemo + title={title} + theme={theme} + active={active} + url={url} + fontSize={fontSizes?.cardTitle} + classString={classNames?.cardTitle} + /> + {canExpand ? ( + <ButtonWrapper> + <ShowOrHideTextButtonMemo + onToggle={toggleText} + show={showText} + textOverlay + theme={theme} + /> + <ExpandButtonMemo + theme={theme} + expanded={expandDetails} + onExpand={toggleExpand} + textOverlay + /> + </ButtonWrapper> + ) : null} + </CardMediaHeader> + {showText && ( + <SubTitleMemo + content={content} + fontSize={fontSizes?.cardSubtitle} + classString={classNames?.cardSubTitle} + padding + theme={theme} + /> + )} + {canShowTextMemo ? ( + <> + <DetailsTextMemo + theme={theme} + show={showText} + expand={expandDetails} + text={detailsText} + onRender={onDetailsTextRef} + textOverlay={textOverlay} + /> + </> + ) : null} + </MediaDetailsWrapper> + ); + }, [ + showText, + expandDetails, + canShowTextMemo, + gradientColor, + title, + JSON.stringify(theme), + ]); + + const canShowProgressBar = useMemo( + () => showProgressBar && textOverlay, + [showProgressBar, textOverlay], + ); + + return ( + <> + <MediaWrapper + theme={theme} + $active={active} + mode={mode} + $slideShowActive={slideshowActive} + className={cls('card-media-wrapper', classNames?.cardMedia)} + $cardHeight={getCardHeight} + align={mediaSettings?.align} + $textOverlay={textOverlay} + > + {media.type === 'VIDEO' && + !isYouTube && + (!loadFailed ? ( + Video + ) : ( + <ErrorMessageMem message="Failed to load the video" /> + ))} + {media.type === 'VIDEO' && isYouTube && IFrameYouTube} + {media.type === 'IMAGE' && + (!loadFailed ? ( + <ImageWrapper height={mediaHeight}>{Image}</ImageWrapper> + ) : ( + <ErrorMessageMem message="Failed to load the image." /> + ))} + + {canShowProgressBar ? ( + <SlideShowProgressBar + color={theme?.primary} + $duration={remainInterval} + $paused={paused} + ref={progressRef} + $startWidth={startWidth} + role="progressbar" + $resuming={resuming} + ></SlideShowProgressBar> + ) : null} + + {canShowArrow ? ( + <TriangleIconWrapper + dir={triangleDir} + theme={theme} + offset={-15} + role="img" + data-testid="arrow-icon" + ></TriangleIconWrapper> + ) : null} + </MediaWrapper> + {canShowTextContent ? TextContent : null} + </> + ); +}; + +CardMedia.displayName = 'Card Media'; + +export default CardMedia; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-horizontal-card.styles.ts | +
+
+ |
+ 97.33% | +146/150 | +63.63% | +14/22 | +100% | +9/9 | +97.33% | +146/150 | +
timeline-horizontal-card.tsx | +
+
+ |
+ 96.41% | +188/195 | +60% | +9/15 | +66.66% | +2/3 | +96.41% | +188/195 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +18x +18x +18x + + + + +18x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import styled, { keyframes } from 'styled-components'; + +export const Wrapper = styled.div` + align-items: center; + border: 1px solid transparent; + display: flex; + justify-content: center; + position: relative; + width: 100%; + height: 100%; + + &.vertical { + justify-content: flex-start; + } +`; + +export const Item = styled.div``; + +const show = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +export const ShapeWrapper = styled.div` + /* height: 100%; */ + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + width: 5em; +`; + +type ShapeModel = { + $timelinePointShape?: 'circle' | 'square' | 'diamond'; + dimension?: number; + theme?: Theme; +}; + +const ShapeBorderStyle = (p: Pick<ShapeModel, '$timelinePointShape'>) => { + if (p.$timelinePointShape === 'circle') { + return 'border-radius: 50%;'; + } else if (p.$timelinePointShape === 'square') { + return 'border-radius: 2px;'; + } else if (p.$timelinePointShape === 'diamond') { + return `border-radius: 0;`; + } +}; + +export const Shape = styled.div<ShapeModel>` + ${ShapeBorderStyle} + cursor: pointer; + height: ${(p) => p.dimension}px; + width: ${(p) => p.dimension}px; + transform: ${(p) => + p.$timelinePointShape === 'diamond' ? 'rotate(45deg)' : ''}; + + &.active { + &.using-icon { + /* transform: scale(1.75); */ + } + &:not(.using-icon) { + transform: ${(p) => + p.$timelinePointShape === 'diamond' ? 'rotate(45deg)' : ''}; + } + + &::after { + ${ShapeBorderStyle} + content: ''; + display: block; + height: ${(p) => (p.dimension ? Math.round(p.dimension * 0.75) : 20)}px; + width: ${(p) => (p.dimension ? Math.round(p.dimension * 0.75) : 20)}px; + left: 50%; + position: absolute; + top: 50%; + transform: translateY(-50%) translateX(-50%); + z-index: -1; + } + } + + &:not(.using-icon) { + background: ${(p: ShapeModel) => p.theme?.primary}; + + &.active { + background: ${(p: ShapeModel) => p.theme?.secondary}; + border: ${(p) => (p.dimension ? Math.round(p.dimension * 0.2) : '3')}px + solid ${(p: ShapeModel) => p.theme?.primary}; + } + + &.in-active { + } + } + + &.using-icon { + background: ${(p) => p.theme?.iconBackgroundColor}; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 90%; + max-height: 90%; + } + } +`; + +export const TimelineTitleContainer = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + + &.vertical { + margin-bottom: 1em; + } + + &.horizontal { + position: absolute; + top: 0; + } +`; + +export const TimelineContentContainer = styled.div<{ + $active?: boolean; + $cardWidth?: number; + $highlight?: boolean; + position?: string; + theme?: Theme; +}>` + align-items: flex-start; + animation: ${show} 0.25s ease-in; + + outline: 2px solid + ${(p) => (p.$highlight && p.$active ? p.theme?.primary : 'transparent')}; + + margin: 1rem; + + &.horizontal { + min-width: ${(p) => p.$cardWidth}px; + } + + &.vertical { + width: calc(100% - 5em); + margin-left: auto; + flex-direction: column; + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x + + + + +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x + + + +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +1x +1x + | import { TimelineCardModel } from '@models/TimelineItemModel'; +import cls from 'classnames'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; +import ReactDOM from 'react-dom'; +import { GlobalContext } from '../../GlobalContext'; +import TimelineCardContent from '../timeline-card-content/timeline-card-content'; +import TimelineItemTitle from '../timeline-item-title/timeline-card-title'; +import { + Shape, + ShapeWrapper, + TimelineContentContainer, + TimelineTitleContainer, + Wrapper, +} from './timeline-horizontal-card.styles'; + +const TimelineCard: React.FunctionComponent<TimelineCardModel> = ({ + active, + autoScroll, + cardDetailedText, + cardSubtitle, + cardTitle, + url, + id, + media, + onClick, + onElapsed, + slideShowRunning, + title, + wrapperId, + customContent, + hasFocus, + iconChild, + timelineContent, + cardWidth, + isNested, + nestedCardHeight, + items, +}: TimelineCardModel) => { + const circleRef = useRef<HTMLDivElement>(null); + const wrapperRef = useRef<HTMLDivElement>(null); + const contentRef = useRef<HTMLDivElement>(null); + + const { + mode, + cardPositionHorizontal: position, + timelinePointDimension, + disableClickOnCircle, + cardLess, + showAllCardsHorizontal, + classNames, + theme, + timelinePointShape, + } = useContext(GlobalContext); + + const handleClick = () => { + if (!disableClickOnCircle && onClick && !slideShowRunning) { + onClick(id); + } + }; + + useEffect(() => { + if (active) { + const circle = circleRef.current; + const wrapper = wrapperRef.current; + + if (circle && wrapper) { + const circleOffsetLeft = circle.offsetLeft; + const wrapperOffsetLeft = wrapper.offsetLeft; + + autoScroll?.({ + pointOffset: circleOffsetLeft + wrapperOffsetLeft, + pointWidth: circle.clientWidth, + }); + } + } + }, [active, autoScroll, mode]); + + const handleOnShowMore = useCallback(() => {}, []); + + const modeLower = useMemo(() => mode?.toLowerCase(), [mode]); + + const containerClass = useMemo( + () => + cls( + 'timeline-horz-card-wrapper', + modeLower, + position === 'TOP' ? 'bottom' : 'top', + showAllCardsHorizontal ? 'show-all' : '', + ), + [mode, position], + ); + + const titleClass = useMemo(() => cls(modeLower, position), []); + + const circleClass = useMemo( + () => + cls( + 'timeline-circle', + { 'using-icon': !!iconChild }, + modeLower, + active ? 'active' : 'in-active', + ), + [active], + ); + + const Content = useMemo(() => { + return ( + <TimelineContentContainer + className={containerClass} + ref={contentRef} + id={`timeline-card-${id}`} + theme={theme} + $active={active} + $highlight={showAllCardsHorizontal} + tabIndex={0} + $cardWidth={cardWidth} + > + <TimelineCardContent + content={cardSubtitle} + active={active} + title={cardTitle} + url={url} + detailedText={cardDetailedText} + onShowMore={handleOnShowMore} + theme={theme} + slideShowActive={slideShowRunning} + media={media} + onElapsed={onElapsed} + id={id} + customContent={customContent} + hasFocus={hasFocus} + onClick={onClick} + timelineContent={timelineContent} + isNested={isNested} + nestedCardHeight={nestedCardHeight} + items={items} + /> + </TimelineContentContainer> + ); + }, [active, slideShowRunning, JSON.stringify(theme)]); + + const showTimelineContent = () => { + const ele = document.getElementById(wrapperId); + + if (ele) { + return ReactDOM.createPortal(Content, ele); + } + }; + + const canShowTimelineContent = useMemo( + () => (active && !cardLess) || showAllCardsHorizontal, + [active, cardLess, showAllCardsHorizontal], + ); + + return ( + <Wrapper ref={wrapperRef} className={modeLower} data-testid="timeline-item"> + {canShowTimelineContent && showTimelineContent()} + + <ShapeWrapper> + <Shape + className={circleClass} + onClick={handleClick} + ref={circleRef} + data-testid="timeline-circle" + theme={theme} + aria-label={title} + dimension={timelinePointDimension} + $timelinePointShape={timelinePointShape} + > + {iconChild ? iconChild : null} + </Shape> + </ShapeWrapper> + + <TimelineTitleContainer + className={titleClass} + data-testid="timeline-title" + > + <TimelineItemTitle + title={title} + active={active} + theme={theme} + classString={classNames?.title} + /> + </TimelineTitleContainer> + </Wrapper> + ); +}; + +export default TimelineCard; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-control.styles.ts | +
+
+ |
+ 100% | +85/85 | +87.5% | +7/8 | +100% | +2/2 | +100% | +85/85 | +
timeline-control.tsx | +
+
+ |
+ 97.2% | +209/215 | +32.35% | +11/34 | +100% | +1/1 | +97.2% | +209/215 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +21x +16x +16x +21x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme';
+import { TimelineMode } from '@models/TimelineModel';
+import styled from 'styled-components';
+
+export const TimelineNavWrapper = styled.ul<{ theme?: Theme }>`
+ background: rgba(229, 229, 229, 0.85);
+ border-radius: 25px;
+ display: flex;
+ list-style: none;
+ padding: 0.25em 0.25em;
+`;
+
+export const TimelineNavItem = styled.li<{ $disable?: boolean }>`
+ padding: 0.1em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ ${(p) => (p.$disable ? 'pointer-events: none; filter: opacity(0.7)' : '')};
+`;
+
+export const TimelineNavButton = styled.button<{
+ mode?: TimelineMode;
+ rotate?: 'TRUE' | 'FALSE';
+ theme?: Theme;
+}>`
+ align-items: center;
+ background: ${(p) => p.theme.primary};
+ border-radius: 50%;
+ border: 0;
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ filter: drop-shadow(0 0 5px rgba(0, 0, 0, 0.25));
+ height: 20px;
+ justify-content: center;
+ margin: 0 0.2em;
+ padding: 0;
+ transition: all 0.1s ease-in;
+ width: 20px;
+
+ transform: ${(p) => {
+ if (p.rotate === 'TRUE') {
+ return `rotate(90deg)`;
+ }
+ }};
+
+ &:active {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.25));
+ transform: ${(p) => (p.rotate === 'TRUE' ? 'rotate(90deg)' : '')} scale(0.9);
+ }
+
+ svg {
+ width: 80%;
+ height: 80%;
+ }
+`;
+
+export const TimelineControlContainer = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+`;
+
+export const ControlButton = styled.button<{ theme?: Theme }>`
+ align-items: center;
+ background: ${(p) => p.theme.primary};
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ height: 2em;
+ justify-content: center;
+ margin-left: 0.5em;
+ width: 2em;
+ outline: 0;
+ color: #fff;
+
+ svg {
+ width: 80%;
+ height: 80%;
+ }
+`;
+
+export const MediaToggle = styled(ControlButton)``;
+
+export const ReplayWrapper = styled(ControlButton)``;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x + + + + + +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x + +4x +4x +4x +4x +1x +1x +1x +1x + | import { TimelineControlModel } from '@models/TimelineControlModel'; +import cls from 'classnames'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import { MoonIcon, StopIcon, SunIcon } from '../../icons'; +import ChevronLeft from '../../icons/chev-left'; +import ChevronRightIcon from '../../icons/chev-right'; +import ChevronsLeftIcon from '../../icons/chevs-left'; +import ChevronsRightIcon from '../../icons/chevs-right'; +import ReplayIcon from '../../icons/replay-icon'; +import { + TimelineControlContainer, + TimelineNavButton, + TimelineNavItem, + TimelineNavWrapper, +} from './timeline-control.styles'; + +/** + * TimelineControl component + * Provides navigation controls for a timeline, including next, previous, first, last, and slideshow buttons. + * Optionally supports flipping the layout and dark mode toggle. + * + * @property {function} onNext - Function to go to the next item. + * @property {function} onPrevious - Function to go to the previous item. + * @property {function} onFirst - Function to jump to the first item. + * @property {function} onLast - Function to jump to the last item. + * @property {boolean} disableLeft - Whether to disable the left navigation buttons. + * @property {boolean} disableRight - Whether to disable the right navigation buttons. + * @property {boolean} slideShowRunning - Whether the slideshow is currently running. + * @property {function} onReplay - Function to restart the slideshow. + * @property {boolean} slideShowEnabled - Whether the slideshow feature is enabled. + * @property {function} onToggleDarkMode - Function to toggle dark mode (if enabled). + * @property {boolean} isDark - Whether dark mode is currently active. + * @property {function} onPaused - Function to pause the slideshow (if running). + * @returns {JSX.Element} The TimelineControl component. + */ +const TimelineControl: React.FunctionComponent<TimelineControlModel> = ({ + onNext, + onPrevious, + onFirst, + onLast, + disableLeft, + disableRight, + slideShowRunning, + onReplay, + slideShowEnabled, + onToggleDarkMode, + isDark, + onPaused, +}: TimelineControlModel) => { + const { mode, flipLayout, theme, buttonTexts, classNames, enableDarkToggle } = + useContext(GlobalContext); + + const rotate = useMemo(() => mode !== 'HORIZONTAL', [mode]); + + const flippedHorizontally = useMemo( + () => flipLayout && mode === 'HORIZONTAL', + [], + ); + + const canDisableLeft = useMemo( + () => disableLeft || slideShowRunning, + [disableLeft, slideShowRunning], + ); + + const canDisableRight = useMemo( + () => disableRight || slideShowRunning, + [disableRight, slideShowRunning], + ); + + const handlePlayOrPause = useCallback(() => { + if (slideShowRunning) { + onPaused?.(); + } else { + onReplay?.(); + } + }, [slideShowRunning]); + + const previousTitle = useMemo( + () => (flipLayout ? buttonTexts?.next : buttonTexts?.previous), + [flipLayout], + ); + + const nextTitle = useMemo( + () => (flipLayout ? buttonTexts?.previous : buttonTexts?.next), + [flipLayout], + ); + + const playOrPauseTile = useMemo( + () => (slideShowRunning ? buttonTexts?.stop : buttonTexts?.play), + [slideShowRunning], + ); + + const jumpToLastTitle = useMemo( + () => (flipLayout ? buttonTexts?.first : buttonTexts?.last), + [flipLayout], + ); + + const jumpToFirstTitle = useMemo( + () => (flipLayout ? buttonTexts?.last : buttonTexts?.first), + [flipLayout], + ); + + return ( + <TimelineControlContainer> + <TimelineNavWrapper + className={cls('timeline-controls', classNames?.controls)} + > + {/* jump to first */} + <TimelineNavItem $disable={canDisableLeft}> + <TimelineNavButton + mode={mode} + theme={theme} + onClick={flippedHorizontally ? onLast : onFirst} + title={jumpToFirstTitle} + aria-label={jumpToFirstTitle} + aria-disabled={disableLeft} + aria-controls="timeline-main-wrapper" + tabIndex={!disableLeft ? 0 : -1} + rotate={rotate ? 'TRUE' : 'FALSE'} + > + <ChevronsLeftIcon /> + </TimelineNavButton> + </TimelineNavItem> + + {/* previous */} + <TimelineNavItem $disable={canDisableLeft}> + <TimelineNavButton + mode={mode} + theme={theme} + onClick={flippedHorizontally ? onNext : onPrevious} + title={previousTitle} + aria-label={previousTitle} + aria-disabled={disableLeft} + aria-controls="timeline-main-wrapper" + tabIndex={!disableLeft ? 0 : -1} + rotate={rotate ? 'TRUE' : 'FALSE'} + > + <ChevronLeft /> + </TimelineNavButton> + </TimelineNavItem> + + {/* next */} + <TimelineNavItem $disable={canDisableRight}> + <TimelineNavButton + mode={mode} + theme={theme} + onClick={flippedHorizontally ? onPrevious : onNext} + title={nextTitle} + aria-label={nextTitle} + aria-disabled={disableRight} + aria-controls="timeline-main-wrapper" + rotate={rotate ? 'TRUE' : 'FALSE'} + tabIndex={!disableRight ? 0 : -1} + > + <ChevronRightIcon /> + </TimelineNavButton> + </TimelineNavItem> + + {/* jump to last */} + <TimelineNavItem $disable={canDisableRight}> + <TimelineNavButton + mode={mode} + theme={theme} + onClick={flippedHorizontally ? onFirst : onLast} + title={jumpToLastTitle} + aria-label={jumpToLastTitle} + aria-disabled={disableRight} + aria-controls="timeline-main-wrapper" + tabIndex={!disableRight ? 0 : -1} + rotate={rotate ? 'TRUE' : 'FALSE'} + > + <ChevronsRightIcon /> + </TimelineNavButton> + </TimelineNavItem> + + {/* slideshow button */} + <TimelineNavItem> + {slideShowEnabled && ( + <TimelineNavButton + theme={theme} + onClick={handlePlayOrPause} + title={playOrPauseTile} + tabIndex={0} + aria-controls="timeline-main-wrapper" + aria-label={playOrPauseTile} + > + {slideShowRunning ? <StopIcon /> : <ReplayIcon />} + </TimelineNavButton> + )} + </TimelineNavItem> + + {/* dark toggle button */} + {enableDarkToggle ? ( + <TimelineNavItem $disable={slideShowRunning}> + <TimelineNavButton + theme={theme} + onClick={onToggleDarkMode} + title={isDark ? buttonTexts?.light : buttonTexts?.dark} + tabIndex={0} + aria-controls="timeline-main-wrapper" + aria-label={isDark ? buttonTexts?.light : buttonTexts?.dark} + > + {isDark ? <SunIcon /> : <MoonIcon />} + </TimelineNavButton> + </TimelineNavItem> + ) : null} + </TimelineNavWrapper> + </TimelineControlContainer> + ); +}; + +TimelineControl.displayName = 'Timeline Control'; + +export default TimelineControl; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-card-title.styles.ts | +
+
+ |
+ 100% | +24/24 | +85.71% | +12/14 | +100% | +5/5 | +100% | +24/24 | +
timeline-card-title.tsx | +
+
+ |
+ 100% | +49/49 | +100% | +4/4 | +100% | +1/1 | +100% | +49/49 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import styled from 'styled-components'; + +export const TitleWrapper = styled.div<{ + $fontSize?: string; + $hide?: boolean; + align?: string; + theme?: Theme; +}>` + border-radius: 0.2rem; + font-size: ${(p) => (p.$fontSize ? p.$fontSize : '1rem')}; + font-weight: 600; + overflow: hidden; + padding: 0.25rem; + visibility: ${(p) => (p.$hide ? 'hidden' : 'visible')}; + text-align: ${(p) => (p.align ? p.align : '')}; + color: ${(p) => (p.theme ? p.theme.titleColor : '')}; + + &.active { + background: ${(p) => p.theme?.secondary}; + color: ${(p) => + p.theme?.titleColorActive ? p.theme?.titleColorActive : p.theme?.primary}; + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +1x +1x + | import { TitleModel } from '@models/TimelineCardTitleModel'; +import cls from 'classnames'; +import React, { useContext, useMemo } from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import { TitleWrapper } from './timeline-card-title.styles'; + +/** + * TimelineItemTitle component + * This component renders the title of a timeline item and applies appropriate styling based on the given props. + * + * @property {string} title - The text of the title. + * @property {boolean} active - Indicates whether the title is active or not. + * @property {Theme} theme - The theme object, used for styling. + * @property {string} align - The alignment of the title. + * @property {string} classString - Additional CSS classes for the title. + * @returns {JSX.Element} The TimelineItemTitle component. + */ +const TimelineItemTitle: React.FunctionComponent<TitleModel> = ({ + title, + active, + theme, + align, + classString, +}: TitleModel) => { + const TITLE_CLASS = 'timeline-item-title'; // Base class name for the title + + // Computed class name for the title, combining base class, active state, and additional classes + const titleClass = useMemo( + () => cls(TITLE_CLASS, active ? 'active' : '', classString), + [active, classString], + ); + + // Get font size from global context + const { fontSizes } = useContext(GlobalContext); + + return ( + <TitleWrapper + className={titleClass} + theme={theme} + $hide={!title} + align={align} + $fontSize={fontSizes?.title} + > + {title} + </TitleWrapper> + ); +}; + +export default TimelineItemTitle; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-outline-item-list.tsx | +
+
+ |
+ 64.58% | +31/48 | +100% | +0/0 | +0% | +0/1 | +64.58% | +31/48 | +
timeline-outline.styles.ts | +
+
+ |
+ 93.93% | +155/165 | +100% | +0/0 | +0% | +0/7 | +93.93% | +155/165 | +
timeline-outline.tsx | +
+
+ |
+ 47.27% | +52/110 | +100% | +1/1 | +0% | +0/1 | +47.27% | +52/110 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + +1x +1x +1x + | import { Theme } from '@models/Theme'; +import { + List, + ListItem, + ListItemBullet, + ListItemName, +} from './timeline-outline.styles'; +import { FunctionComponent } from 'react'; +import { TimelineOutlineItem } from './timeline-outline'; + +interface OutlineItemListModel { + handleSelection: (index: number, id?: string) => void; + items: TimelineOutlineItem[]; + theme: Theme; +} + +/** + * OutlineItemList component + * This component is responsible for rendering the outline list of items. + * It takes a list of items, a theme, and a selection handler function as props, + * and maps through the items to render each one within the list. + * + * @property {TimelineOutlineItem[]} items - The items to be displayed in the list. + * @property {Theme} theme - The theme object, used for styling. + * @property {function} handleSelection - The callback to be invoked when an item is selected. + * @returns {JSX.Element} The rendered OutlineItemList component. + */ +const OutlineItemList: FunctionComponent<OutlineItemListModel> = ({ + items, + handleSelection, + theme, +}) => ( + <List> + {items.map((item, index) => ( + <ListItem + key={item.id} + onPointerDown={() => handleSelection(index, item.id)} + > + <ListItemBullet theme={theme} selected={item.selected}></ListItemBullet> + <ListItemName theme={theme} selected={item.selected}> + {item.name} + </ListItemName> + </ListItem> + ))} + </List> +); + +export { OutlineItemList }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + +1x +1x +1x + | import { Theme } from '@models/Theme'; +import styled, { keyframes } from 'styled-components'; +import { OutlinePosition } from './timeline-outline'; + +const open = keyframes` + from { + width: 30px; + height: 30px; + } + + to: { + width: 200px; + height: 50%; + } + `; + +const close = keyframes` + from { + width: 200px; + height: 50%; + } + + to: { + width: 30px; + height: 30px; + } +`; + +export const OutlineWrapper = styled.div<{ + open?: boolean; + position?: OutlinePosition; +}>` + animation: ${(p) => (p.open ? open : close)}; + animation-duration: 0.2s; + animation-timing-function: ease-in; + background: rgba(255, 255, 255, 0.98); + border: 1px solid ${(p) => (p.open ? '#f5f5f5' : 'none')}; + height: 50%; + position: absolute; + top: 1rem; + width: 100%; + z-index: 9000; + ${(p) => + p.position === OutlinePosition.left ? `left: 1rem;` : `right: 3rem;`}; + ${(p) => + p.open + ? ` + width: 200px; + height: 50%; + box-shadow: 0 5px 10px 2px rgba(0,0,0,0.2); + overflow-y: auto;` + : `width: 30px; height: 30px;`}; +`; + +export const OutlinePane = styled.aside<{ open?: boolean }>` + align-items: center; + border-radius: 4px; + display: flex; + justify-content: center; +`; + +export const OutlineButton = styled.button<{ + open?: boolean; + position?: OutlinePosition; + theme?: Theme; +}>` + align-items: center; + align-self: flex-end; + background: #fff; + border-radius: 4px; + border: 0; + box-shadow: ${(p) => (!p.open ? '0 0 10px 2px rgba(0,0,0,0.2)' : 'none')}; + cursor: pointer; + display: flex; + height: 30px; + justify-content: center; + padding: 0; + width: 30px; + + ${(p) => + p.position === OutlinePosition.left + ? 'margin-right: auto;' + : 'margin-left: auto;'}; + + & svg { + width: 70%; + height: 70%; + } + + & svg path { + color: ${(p) => p.theme.primary}; + } +`; + +export const List = styled.ul` + display: flex; + flex-direction: column; + height: 100%; + list-style: none; + margin: 0; + overflow-y: auto; + padding: 0; + width: 80%; +`; + +export const ListItem = styled.li` + align-items: center; + display: flex; + font-size: 0.9rem; + justify-content: flex-start; + margin: 0.75rem 0; + cursor: pointer; + position: relative; + + &:not(:last-child)::after { + content: ''; + display: block; + width: 100%; + position: absolute; + height: 1px; + background: #ddd; + left: 0; + right: 0; + margin: 0 auto; + bottom: -50%; + } +`; + +export const ListItemName = styled.span<{ selected?: boolean; theme?: Theme }>` + font-size: 0.75rem; + color: ${(p) => (p.selected ? p.theme.primary : '')}; + padding-left: 0.25rem; + + &:hover { + color: ${(p) => p.theme.primary}; + } +`; + +export const ListItemBullet = styled.span<{ + selected?: boolean; + theme?: Theme; +}>` + align-items: center; + display: flex; + justify-content: center; + margin-right: 1rem; + position: relative; + + &::after { + content: ''; + display: block; + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(p) => + p.selected ? `${p.theme.secondary}` : `${p.theme.primary}`}; + left: 0; + margin: 0 auto; + border: ${(p) => + p.selected + ? `2px solid ${p.theme.secondary}` + : `2px solid ${p.theme.primary}`}; + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x + | import { Theme } from '@models/Theme'; +import { TimelineMode } from '@models/TimelineModel'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { GlobalContext } from '../../GlobalContext'; +import CloseIcon from '../../icons/close'; +import MenuIcon from '../../icons/menu'; +import { + OutlineButton, + OutlinePane, + OutlineWrapper, +} from './timeline-outline.styles'; +import { OutlineItemList } from './timeline-outline-item-list'; + +export enum OutlinePosition { + 'left', + 'right', +} + +interface TimelineOutlineModel { + items?: TimelineOutlineItem[]; + mode?: TimelineMode; + onSelect?: (index: number) => void; + theme?: Theme; +} + +export interface TimelineOutlineItem { + id?: string; + name?: string; + selected?: boolean; +} + +/** + * TimelineOutline component + * This component renders the outline pane of a timeline, including a list of items and corresponding selection functionality. + * It provides an interface to toggle the outline pane and select items within the timeline. + * The component leverages memoization to prevent unnecessary re-renders and optimizes the rendering process. + * + * @property {TimelineOutlineItem[]} items - The items to be displayed in the outline. + * @property {TimelineMode} mode - The mode of the timeline which determines the outline position. + * @property {function} onSelect - The callback to be invoked when an item is selected. + * @property {Theme} theme - The theme object, used for styling. + * @returns {JSX.Element} The TimelineOutline component. + */ +const TimelineOutline: React.FC<TimelineOutlineModel> = ({ + items = [], + onSelect, + mode, + theme, +}: TimelineOutlineModel) => { + const [openPane, setOpenPane] = useState(false); + const [showList, setShowList] = useState(false); + + const { theme: globalTheme } = useContext(GlobalContext); + const mergedTheme = theme || globalTheme; + + const togglePane = useCallback(() => setOpenPane((prev) => !prev), []); + + const position = useMemo( + () => + mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING' + ? OutlinePosition.right + : OutlinePosition.left, + [mode], + ); + + useEffect(() => { + if (openPane) { + setShowList(true); + } else { + setShowList(false); + } + }, [openPane]); + + const handleSelection = useCallback( + (index: number, id?: string) => { + if (onSelect) onSelect(index); + }, + [onSelect], + ); + + return ( + <OutlineWrapper position={position} open={openPane}> + <OutlineButton + onPointerDown={togglePane} + theme={mergedTheme} + open={openPane} + position={position} + > + {openPane ? <CloseIcon /> : <MenuIcon />} + </OutlineButton> + <OutlinePane open={openPane}> + {showList && ( + <OutlineItemList + items={items} + handleSelection={handleSelection} + theme={mergedTheme} + /> + )} + </OutlinePane> + </OutlineWrapper> + ); +}; + +export { TimelineOutline }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-horizontal.styles.ts | +
+
+ |
+ 100% | +35/35 | +100% | +0/0 | +100% | +0/0 | +100% | +35/35 | +
timeline-horizontal.tsx | +
+
+ |
+ 28.71% | +29/101 | +100% | +0/0 | +0% | +0/1 | +28.71% | +29/101 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import styled from 'styled-components'; + +export const TimelineHorizontalWrapper = styled.ul<{ flipLayout?: boolean }>` + display: flex; + list-style: none; + margin: 0; + width: 100%; + direction: ${(p) => (p.flipLayout ? 'rtl' : 'ltr')}; + + &.vertical { + flex-direction: column; + } + &.horizontal { + flex-direction: row; + } +`; + +export const TimelineItemWrapper = styled.li<{ width: number }>` + width: ${(p) => p.width}px; + visibility: hidden; + display: flex; + align-items: center; + justify-content: center; + height: 150px; + flex-direction: column; + + &.vertical { + margin-bottom: 2rem; + width: 100%; + } + + &.visible { + visibility: visible; + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x + | import { TimelineHorizontalModel } from '@models/TimelineHorizontalModel'; +import cls from 'classnames'; +import React, { ReactNode, useContext, useMemo } from 'react'; +import { GlobalContext } from '../GlobalContext'; +import TimelineCard from '../timeline-elements/timeline-card/timeline-horizontal-card'; +import { + TimelineHorizontalWrapper, + TimelineItemWrapper, +} from './timeline-horizontal.styles'; + +/** + * TimelineHorizontal + * @property {TimelineHorizontalModel} items - The items to be displayed in the timeline. + * @property {(item: TimelineItem) => void} handleItemClick - Function to handle item click. + * @property {boolean} autoScroll - Whether to auto-scroll the timeline. + * @property {string} wrapperId - The ID of the wrapper element. + * @property {boolean} slideShowRunning - Whether the slideshow is running. + * @property {() => void} onElapsed - Function to handle elapsed time. + * @property {React.ReactNode} contentDetailsChildren - The children nodes for content details. + * @property {boolean} hasFocus - Whether the timeline has focus. + * @property {React.ReactNode} iconChildren - The children nodes for icons. + * @property {number} nestedCardHeight - The height of the nested card. + * @property {boolean} isNested - Whether the card is nested. + * @returns {JSX.Element} The TimelineHorizontal component. + */ + +const TimelineHorizontal: React.FunctionComponent<TimelineHorizontalModel> = ({ + items, + handleItemClick, + autoScroll, + wrapperId, + slideShowRunning, + onElapsed, + contentDetailsChildren: children, + hasFocus, + iconChildren, + nestedCardHeight, + isNested, +}: TimelineHorizontalModel) => { + const { + mode = 'HORIZONTAL', + itemWidth = 200, + cardHeight, + flipLayout, + showAllCardsHorizontal, + theme, + cardWidth, + } = useContext(GlobalContext); + + // Memoize the wrapper class to avoid unnecessary re-renders + const wrapperClass = useMemo( + () => + cls( + mode.toLowerCase(), + 'timeline-horizontal-container', + showAllCardsHorizontal ? 'show-all-cards-horizontal' : '', + ), + [mode, showAllCardsHorizontal], + ); + + const iconChildColln = React.Children.toArray(iconChildren); + + return ( + <TimelineHorizontalWrapper + className={wrapperClass} + flipLayout={flipLayout} + data-testid="timeline-collection" + > + {items.map((item, index) => ( + <TimelineItemWrapper + key={item.id} + width={itemWidth} + className={cls( + item.visible ? 'visible' : '', + 'timeline-horz-item-container', + )} + > + <TimelineCard + {...item} + onClick={handleItemClick} + autoScroll={autoScroll} + wrapperId={wrapperId} + theme={theme} + slideShowRunning={slideShowRunning} + cardHeight={cardHeight} + onElapsed={onElapsed} + customContent={children ? (children as ReactNode[])[index] : null} + hasFocus={hasFocus} + iconChild={iconChildColln[index]} + active={item.active} + cardWidth={cardWidth} + isNested={isNested} + nestedCardHeight={nestedCardHeight} + /> + </TimelineItemWrapper> + ))} + </TimelineHorizontalWrapper> + ); +}; + +export default TimelineHorizontal; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline-point.tsx | +
+
+ |
+ 92.36% | +121/131 | +60% | +6/10 | +0% | +0/1 | +92.36% | +121/131 | +
timeline-vertical-item.tsx | +
+
+ |
+ 96.17% | +226/235 | +33.33% | +4/12 | +100% | +1/1 | +96.17% | +226/235 | +
timeline-vertical-shape.styles.ts | +
+
+ |
+ 100% | +50/50 | +100% | +11/11 | +100% | +4/4 | +100% | +50/50 | +
timeline-vertical.styles.ts | +
+
+ |
+ 84.61% | +121/143 | +42.1% | +8/19 | +100% | +5/5 | +84.61% | +121/143 | +
timeline-vertical.tsx | +
+
+ |
+ 24% | +30/125 | +100% | +0/0 | +0% | +0/1 | +24% | +30/125 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x + +3x +3x +3x +3x +3x +3x +3x +3x + + + + +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x + + + + + +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +1x +1x +1x +1x +1x +1x + | import { TimelinePointModel } from '@models/TimelineVerticalModel'; +import cls from 'classnames'; +import React, { memo, useContext, useEffect, useMemo, useRef } from 'react'; +import { GlobalContext } from '../GlobalContext'; +import { Shape } from '../timeline-elements/timeline-card/timeline-horizontal-card.styles'; +import { + TimelinePointContainer, + TimelinePointWrapper, +} from './timeline-vertical-shape.styles'; + +/** + * TimelinePoint + * @property {string} className - The class name for the component. + * @property {string} id - The id of the timeline point. + * @property {() => void} onClick - Function to handle click event. + * @property {boolean} active - Whether the timeline point is active. + * @property {() => void} onActive - Function to handle active event. + * @property {boolean} slideShowRunning - Whether the slideshow is running. + * @property {React.ReactNode} iconChild - The icon child nodes. + * @property {number} timelinePointDimension - The dimension of the timeline point. + * @property {number} lineWidth - The width of the line. + * @property {boolean} disableClickOnCircle - Whether the click on circle is disabled. + * @property {boolean} cardLess - Whether the card is less. + * @returns {JSX.Element} The TimelinePoint component. + */ +const TimelinePoint: React.FunctionComponent<TimelinePointModel> = memo( + (props: TimelinePointModel) => { + const { + className, + id, + onClick, + active, + onActive, + slideShowRunning, + iconChild, + timelinePointDimension, + lineWidth, + disableClickOnCircle, + cardLess, + } = props; + + const circleRef = useRef<HTMLDivElement>(null); + const { theme, focusActiveItemOnLoad, timelinePointShape } = + useContext(GlobalContext); + + const isFirstRender = useRef(true); + + // Determine if onActive can be invoked + const canInvokeOnActive = useMemo(() => { + if (focusActiveItemOnLoad) { + return active; + } else { + return active && !isFirstRender.current; + } + }, [active]); + + // Invoke onActive if conditions are met + useEffect(() => { + if (canInvokeOnActive) { + const circle = circleRef.current; + + circle && onActive(circle.offsetTop); + } + }, [canInvokeOnActive, active]); + + // Determine circle class + const circleClass = useMemo( + () => + cls({ + active, + 'using-icon': !!iconChild, + }), + [active, iconChild], + ); + + // Determine click handler props + const clickHandlerProps = useMemo( + () => + !disableClickOnCircle && { + onClick: (ev: React.MouseEvent) => { + ev.stopPropagation(); + if (id && onClick && !slideShowRunning) { + onClick(id); + } + }, + }, + [id, onClick, slideShowRunning, disableClickOnCircle], + ); + + // Update isFirstRender flag after first render + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + } + }, []); + + return ( + <TimelinePointWrapper + width={lineWidth} + bg={theme && theme.primary} + className={className} + data-testid="tree-leaf" + role="button" + $cardLess={cardLess} + > + <TimelinePointContainer + className={`${className} timeline-vertical-circle`} + {...clickHandlerProps} + ref={circleRef} + role="button" + data-testid="tree-leaf-click" + aria-label="select timeline" + > + <Shape + className={circleClass} + theme={theme} + dimension={timelinePointDimension} + $timelinePointShape={timelinePointShape} + > + {iconChild ? iconChild : null} + </Shape> + </TimelinePointContainer> + </TimelinePointWrapper> + ); + }, + (prev, next) => prev.active === next.active, +); + +TimelinePoint.displayName = 'TimelinePoint'; + +export { TimelinePoint }; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + + + + + +2x +2x +2x +2x +2x + + + +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + +2x +2x +2x +2x +2x +1x +1x +1x +1x + | import { VerticalItemModel } from '@models/TimelineVerticalModel'; +import cls from 'classnames'; +import React, { useCallback, useContext, useMemo, useRef } from 'react'; +import { GlobalContext } from '../GlobalContext'; +import TimelineCard from '../timeline-elements/timeline-card-content/timeline-card-content'; +import TimelineItemTitle from '../timeline-elements/timeline-item-title/timeline-card-title'; +import { TimelinePoint } from './timeline-point'; +import { + TimelineCardContentWrapper, + TimelineTitleWrapper, + VerticalItemWrapper, +} from './timeline-vertical.styles'; + +/** + * VerticalItem + * @property {boolean} active - Whether the vertical item is active. + * @property {boolean} alternateCards - Whether to alternate cards. + * @property {string} cardDetailedText - The detailed text of the card. + * @property {string} cardSubtitle - The subtitle of the card. + * @property {string} cardTitle - The title of the card. + * @property {string} url - The URL of the card. + * @property {string} className - The class name for the component. + * @property {React.ReactNode} contentDetailsChildren - The content details children nodes. + * @property {React.ReactNode} iconChild - The icon child nodes. + * @property {boolean} hasFocus - Whether the vertical item has focus. + * @property {string} id - The id of the vertical item. + * @property {React.ReactNode} media - The media nodes. + * @property {() => void} onActive - Function to handle active event. + * @property {() => void} onClick - Function to handle click event. + * @property {() => void} onElapsed - Function to handle elapsed event. + * @property {boolean} slideShowRunning - Whether the slideshow is running. + * @property {string} title - The title of the vertical item. + * @property {boolean} visible - Whether the vertical item is visible. + * @property {React.ReactNode} timelineContent - The timeline content nodes. + * @property {Array} items - The items of the vertical item. + * @property {boolean} isNested - Whether the vertical item is nested. + * @property {number} nestedCardHeight - The height of the nested card. + * @returns {JSX.Element} The VerticalItem component. + */ +const VerticalItem: React.FunctionComponent<VerticalItemModel> = ( + props: VerticalItemModel, +) => { + const contentRef = useRef<HTMLDivElement>(null); + + const { + active, + alternateCards, + cardDetailedText, + cardSubtitle, + cardTitle, + url, + className, + contentDetailsChildren, + iconChild, + hasFocus, + id, + media, + onActive, + onClick, + onElapsed, + slideShowRunning, + title, + visible, + timelineContent, + items, + isNested, + nestedCardHeight, + } = props; + + const { + cardHeight, + mode, + flipLayout, + timelinePointDimension, + lineWidth, + disableClickOnCircle, + cardLess, + theme, + classNames, + textOverlay, + mediaHeight, + } = useContext(GlobalContext); + + // handler for onActive + const handleOnActive = useCallback( + (offset: number) => { + if (contentRef.current) { + const { offsetTop, clientHeight } = contentRef.current; + onActive(offsetTop + offset, offsetTop, clientHeight); + } + }, + [onActive], + ); + + // handler for read more + const handleShowMore = useCallback(() => { + setTimeout(() => { + handleOnActive(0); + }, 100); + }, [handleOnActive]); + + // timeline title + const Title = useMemo(() => { + return ( + <TimelineTitleWrapper + className={className} + $alternateCards={alternateCards} + mode={mode} + $hide={!title} + $flip={flipLayout} + > + <TimelineItemTitle + title={title} + active={active} + theme={theme} + align={flipLayout ? 'left' : 'right'} + classString={classNames?.title} + /> + </TimelineTitleWrapper> + ); + }, [ + active, + title, + className, + alternateCards, + mode, + flipLayout, + theme, + classNames, + ]); + + const verticalItemClass = useMemo( + () => + cls({ [className]: true }, 'vertical-item-row', visible ? 'visible' : ''), + [className, visible], + ); + + const contentClass = cls('card-content-wrapper', visible ? 'visible' : '', { + [className]: true, + }); + + // timeline circle + const TimelinePointMemo = useMemo( + () => ( + <TimelinePoint + active={active} + alternateCards={alternateCards} + className={className} + id={id} + mode={mode} + onActive={handleOnActive} + onClick={onClick} + slideShowRunning={slideShowRunning} + iconChild={iconChild} + timelinePointDimension={timelinePointDimension} + lineWidth={lineWidth} + disableClickOnCircle={disableClickOnCircle} + cardLess={cardLess} + /> + ), + [ + slideShowRunning, + active, + alternateCards, + className, + id, + mode, + handleOnActive, + onClick, + iconChild, + timelinePointDimension, + lineWidth, + disableClickOnCircle, + cardLess, + ], + ); + + return ( + <VerticalItemWrapper + $alternateCards={alternateCards} + $cardHeight={isNested ? nestedCardHeight : cardHeight} + className={verticalItemClass} + data-testid="vertical-item-row" + key={id} + ref={contentRef} + $cardLess={cardLess} + role="listitem" + $isNested={isNested} + theme={theme} + > + {/* title */} + {!isNested ? Title : null} + + {/* card section */} + <TimelineCardContentWrapper + className={contentClass} + $alternateCards={alternateCards} + $noTitle={!title} + $flip={flipLayout} + height={textOverlay ? mediaHeight : cardHeight} + > + {!cardLess ? ( + // <span></span> + <TimelineCard + active={active} + branchDir={className} + content={cardSubtitle} + customContent={contentDetailsChildren} + detailedText={cardDetailedText} + hasFocus={hasFocus} + id={id} + media={media} + onClick={onClick} + onElapsed={onElapsed} + onShowMore={handleShowMore} + slideShowActive={slideShowRunning} + theme={theme} + title={cardTitle} + url={url} + flip={flipLayout} + timelineContent={timelineContent} + items={items} + isNested={isNested} + nestedCardHeight={nestedCardHeight} + /> + ) : null} + </TimelineCardContentWrapper> + {!isNested ? TimelinePointMemo : null} + </VerticalItemWrapper> + ); +}; + +VerticalItem.displayName = 'VerticalItem'; + +export default VerticalItem; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import styled from 'styled-components'; + +export const TimelinePointWrapper = styled.div<{ + $cardLess?: boolean; + bg?: string; + width?: number; +}>` + align-items: center; + display: flex; + justify-content: center; + position: relative; + width: ${(p) => (p.$cardLess ? '5%' : '10%')}; + + &.left { + order: 2; + } + + &.right { + order: 1; + } + + &::before { + background: ${(p) => p.bg}; + width: ${(p) => (p.width ? `${p.width}px` : '4px')}; + height: 2rem; + position: absolute; + content: ''; + display: block; + left: 50%; + top: -1rem; + transform: translateY(-50%) translateX(-50%); + } + + &::after { + background: ${(p) => p.bg}; + content: ''; + display: block; + height: 100%; + left: 50%; + position: absolute; + width: ${(p) => (p.width ? `${p.width}px` : '4px')}; + z-index: 0; + transform: translateX(-50%); + } +`; + +export const TimelinePointContainer = styled.div` + position: relative; + z-index: 1; +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x + + + + + + + + + +2x +2x +2x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x + +2x + +2x +2x +2x +2x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + + + + + + + + + + +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x + +2x +2x +2x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import { TimelineMode } from '@models/TimelineModel'; +import styled, { css, keyframes } from 'styled-components'; + +export const TimelineVerticalWrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 1em; + outline: 0; +`; + +const animateVisible = keyframes` + from { + opacity: 0; + visibility: hidden; + } + to { + opacity: 1; + visibility: visible; + } +`; + +export const VerticalItemWrapper = styled.div<{ + $alternateCards?: boolean; + $cardHeight?: number; + $cardLess?: boolean; + $isNested?: boolean; + theme?: Theme; +}>` + display: flex; + position: relative; + visibility: hidden; + width: 100%; + align-items: stretch; + justify-content: center; + margin: 1rem 0; + + &.left { + margin-right: auto; + } + &.right { + margin-left: auto; + } + + &.visible { + visibility: visible; + } + + ${(p) => + p.$isNested + ? css` + position: relative; + + &:not(:last-child)::after { + content: ''; + position: absolute; + width: 2px; + height: 2rem; + background: ${(p) => p.theme.primary}; + left: 50%; + transform: translateX(-50%); + bottom: -2rem; + } + ` + : css``} +`; + +export const TimelineCardContentWrapper = styled.div<{ + $alternateCards?: boolean; + $cardLess?: boolean; + $flip?: boolean; + $noTitle?: boolean; + height?: number; +}>` + visibility: hidden; + position: relative; + display: flex; + align-items: center; + ${(p) => { + if (p.$alternateCards) { + return `width: 50%;`; + } else if (p.$noTitle) { + return `width: 95%;`; + } else { + return `width: 75%;`; + } + }} + ${(p) => { + if (!p.$flip) { + return ` + &.left { + order: 1; + justify-content: flex-end; + } + &.right { + order: 3; + justify-content: flex-start; + } + `; + } else { + return ` + justify-content: flex-end; + &.left { + order: 3; + } + &.right { + order: 1; + } + `; + } + }} + &.visible { + visibility: visible; + animation: ${animateVisible} 0.25s ease-in; + } +`; + +export const TimelineTitleWrapper = styled.div<{ + $alternateCards?: boolean; + $flip?: boolean; + $hide?: boolean; + mode?: TimelineMode; +}>` + align-items: center; + display: ${(p) => (p.$hide && p.mode === 'VERTICAL' ? 'none' : 'flex')}; + ${(p) => (p.$alternateCards ? 'width: 50%' : 'width: 15%')}; + + &.left { + justify-content: ${(p) => (p.$flip ? 'flex-end' : 'flex-start')}; + order: ${(p) => (p.$flip && p.mode === 'VERTICAL_ALTERNATING' ? '1' : '3')}; + } + + &.right { + ${(p) => + p.$flip + ? ` + order: 3; + justify-content: flex-start;` + : `order: 1; + justify-content: flex-end;`}; + } +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x + | import { TimelineVerticalModel } from '@models/TimelineVerticalModel'; +import React, { useCallback, useMemo } from 'react'; +import { TimelineOutline } from '../timeline-elements/timeline-outline/timeline-outline'; +import TimelineVerticalItem from './timeline-vertical-item'; +import { TimelineVerticalWrapper } from './timeline-vertical.styles'; + +/** + * TimelineVertical + * @property {boolean} alternateCards - Whether to alternate cards. + * @property {() => void} autoScroll - Function to handle auto scroll. + * @property {React.ReactNode} contentDetailsChildren - The content details children nodes. + * @property {boolean} enableOutline - Whether to enable outline. + * @property {boolean} hasFocus - Whether the timeline has focus. + * @property {React.ReactNode} iconChildren - The icon children nodes. + * @property {Array} items - The items of the timeline. + * @property {string} mode - The mode of the timeline. + * @property {() => void} onClick - Function to handle click event. + * @property {() => void} onElapsed - Function to handle elapsed event. + * @property {() => void} onOutlineSelection - Function to handle outline selection. + * @property {boolean} slideShowRunning - Whether the slideshow is running. + * @property {Object} theme - The theme of the timeline. + * @property {boolean} cardLess - Whether the card is less. + * @property {number} nestedCardHeight - The height of the nested card. + * @returns {JSX.Element} The TimelineVertical component. + */ +const TimelineVertical: React.FunctionComponent<TimelineVerticalModel> = ({ + alternateCards = true, + autoScroll, + contentDetailsChildren, + enableOutline, + hasFocus, + iconChildren, + items, + mode, + onClick, + onElapsed, + onOutlineSelection, + slideShowRunning, + theme, + cardLess, + nestedCardHeight, +}: TimelineVerticalModel) => { + // check if the timeline that has become active is visible. + // if not auto scroll the content and bring it to the view. + const handleOnActive = useCallback( + (offset: number, wrapperOffset: number, height: number) => { + autoScroll({ + contentHeight: height, + contentOffset: wrapperOffset, + pointOffset: offset, + }); + }, + [autoScroll], + ); + + // todo remove this + const handleOnShowMore = useCallback(() => {}, []); + + const outlineItems = useMemo( + () => + items.map((item) => ({ + id: Math.random().toString(16).slice(2), + name: item.title, + })), + [items], + ); + + return ( + <TimelineVerticalWrapper data-testid="tree-main" role="list"> + {enableOutline && ( + <TimelineOutline + theme={theme} + mode={mode} + items={outlineItems} + onSelect={onOutlineSelection} + /> + )} + {items.map((item, index) => { + let className = ''; + + // in tree mode alternate cards position + if (alternateCards) { + className = index % 2 === 0 ? 'left' : 'right'; + } else { + className = 'right'; + } + + const contentDetails = + (contentDetailsChildren && + (contentDetailsChildren as React.ReactNode[])[index]) || + null; + + const customIcon = Array.isArray(iconChildren) + ? iconChildren[index] + : index === 0 + ? iconChildren + : null; + + return ( + <TimelineVerticalItem + {...item} + alternateCards={alternateCards} + className={className} + contentDetailsChildren={contentDetails} + iconChild={customIcon} + hasFocus={hasFocus} + index={index} + key={item.id} + onActive={handleOnActive} + onClick={onClick} + onElapsed={onElapsed} + onShowMore={handleOnShowMore} + slideShowRunning={slideShowRunning} + cardLess={cardLess} + nestedCardHeight={nestedCardHeight} + /> + ); + })} + </TimelineVerticalWrapper> + ); +}; + +TimelineVertical.displayName = 'TimelineVertical'; + +export default TimelineVertical; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
timeline.style.ts | +
+
+ |
+ 81.87% | +122/149 | +100% | +0/0 | +0% | +0/3 | +81.87% | +122/149 | +
timeline.tsx | +
+
+ |
+ 7.29% | +34/466 | +100% | +0/0 | +0% | +0/1 | +7.29% | +34/466 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + +1x +1x +1x + + + + + + + + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + | import { Theme } from '@models/Theme'; +import { TimelineMode } from '@models/TimelineModel'; +import styled from 'styled-components'; +import { ScrollBar } from '../common/styles'; + +export const Wrapper = styled.div<{ + $hideControls?: boolean; + cardPositionHorizontal?: 'TOP' | 'BOTTOM'; +}>` + display: flex; + flex-direction: column; + /* cannot remove this */ + height: 100%; + + &:focus { + outline: 0; + } + + overflow: hidden; + position: relative; + width: 100%; + + ${(p) => + p.cardPositionHorizontal === 'TOP' && !p.$hideControls + ? ` + & > div:nth-of-type(1) { + order: 2; + } + & > div:nth-of-type(2) { + order: 3; + } + & > div:nth-of-type(3) { + order: 1; + } + ` + : ''}; + + ${(p) => + p.cardPositionHorizontal === 'TOP' && p.$hideControls + ? ` + & > div:nth-of-type(1) { + order: 2; + } + & > div:nth-of-type(2) { + order: 1; + } + ` + : ''}; + + &.horizontal { + justify-content: flex-start; + } + + &.js-focus-visible :focus:not(.focus-visible) { + outline: 0; + } + + &.js-focus-visible .focus-visible { + outline: 2px solid #528deb; + } +`; + +export const TimelineMainWrapper = styled.div<{ + $scrollable?: boolean | { scrollbar: boolean }; + mode?: TimelineMode; + theme?: Theme; +}>` + align-items: flex-start; + display: flex; + justify-content: center; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + ${(p) => (p.mode === 'HORIZONTAL' ? 'position: relative' : '')}; + scroll-behavior: smooth; + width: 100%; + + ${ScrollBar} + + &.horizontal { + min-height: 150px; + } + + padding: ${({ $scrollable }) => (!$scrollable ? '0 1rem 0' : '')}; +`; + +export const TimelineMain = styled.div` + align-items: center; + display: flex; + left: 0; + top: 50%; + position: absolute; + transition: all 0.2s ease; + transform: translate(0, -50%); + + &.vertical { + align-items: flex-start; + height: 100%; + justify-content: flex-start; + width: 100%; + } +`; + +export const Outline = styled.div<{ color?: string; height?: number }>` + background: ${(p) => p.color}; + height: ${(p) => `${p.height}px`}; + left: 0; + margin-left: auto; + margin-right: auto; + position: absolute; + right: 0; + width: 100%; +`; + +export const TimelineControlContainer = styled.div<{ + active?: boolean; + mode?: TimelineMode; +}>` + align-items: center; + display: flex; + justify-content: center; + min-height: 3rem; + + filter: ${(p) => { + if (p.active) { + return `opacity(1);`; + } else { + return `opacity(0.9);`; + } + }}; + + &.hide { + visibility: hidden; + } + + &.show { + visibility: visible; + } +`; + +export const TimelineContentRender = styled.div<{ $showAllCards?: boolean }>` + margin-left: auto; + margin-right: auto; + width: 98%; + display: flex; + align-items: flex-start; + justify-content: ${(p) => (p.$showAllCards ? 'flex-start' : 'center')}; + overflow-x: hidden; +`; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x + | import { Scroll } from '@models/TimelineHorizontalModel'; +import { TimelineCardModel } from '@models/TimelineItemModel'; +import { TimelineModel } from '@models/TimelineModel'; +import { uniqueID as genUniqueID } from '@utils/index'; +import cls from 'classnames'; +import 'focus-visible'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { GlobalContext } from '../GlobalContext'; +import { useMatchMedia } from '../effects/useMatchMedia'; +import useNewScrollPosition from '../effects/useNewScrollPosition'; +import TimelineControl from '../timeline-elements/timeline-control/timeline-control'; +import TimelineHorizontal from '../timeline-horizontal/timeline-horizontal'; +import TimelineVertical from '../timeline-vertical/timeline-vertical'; +import { + Outline, + TimelineContentRender, + TimelineControlContainer, + TimelineMain, + TimelineMainWrapper, + Wrapper, +} from './timeline.style'; + +const Timeline: React.FunctionComponent<TimelineModel> = ( + props: TimelineModel, +) => { + // de-structure the props + const { + activeTimelineItem, + contentDetailsChildren, + iconChildren, + items = [], + onFirst, + onLast, + onNext, + onPrevious, + onRestartSlideshow, + onTimelineUpdated, + onItemSelected, + onOutlineSelection, + slideShowEnabled, + slideShowRunning, + mode = 'HORIZONTAL', + enableOutline = false, + hideControls = false, + nestedCardHeight, + isChild = false, + onPaused, + uniqueId, + noUniqueId, + } = props; + + const { + cardPositionHorizontal, + disableNavOnKey, + flipLayout, + itemWidth = 200, + lineWidth, + onScrollEnd, + scrollable = true, + showAllCardsHorizontal, + theme, + darkMode, + toggleDarkMode, + verticalBreakPoint = 768, + enableBreakPoint, + } = useContext(GlobalContext); + + const [newOffSet, setNewOffset] = useNewScrollPosition(mode, itemWidth); + const observer = useRef<IntersectionObserver | null>(null); + const [hasFocus, setHasFocus] = useState(false); + const horizontalContentRef = useRef<HTMLDivElement | null>(null); + const [timelineMode, setTimelineMode] = useState(mode); + + // const activeItemIndex = useRef<number>(activeTimelineItem); + + // reference to the timeline + const timelineMainRef = useRef<HTMLDivElement>(null); + + const canScrollTimeline = useMemo(() => { + if (!slideShowRunning) { + if (typeof scrollable === 'boolean') { + return scrollable; + } + + if (typeof scrollable === 'object' && scrollable.scrollbar) { + return scrollable.scrollbar; + } + } + }, [slideShowRunning, scrollable]); + + const id = useRef( + `react-chrono-timeline-${noUniqueId ? uniqueId : genUniqueID()}`, + ); + + useMatchMedia( + `(min-width: 100px) and (max-width: ${verticalBreakPoint}px)`, + () => { + if (mode === 'VERTICAL_ALTERNATING') { + setTimelineMode('VERTICAL'); + } + }, + enableBreakPoint, + ); + + useMatchMedia( + `(min-width: ${verticalBreakPoint + 1}px)`, + () => { + setTimelineMode(mode); + }, + enableBreakPoint, + ); + + // handlers for navigation + const handleNext = useCallback(() => { + hasFocus && onNext?.(); + }, [hasFocus, onNext]); + + const handlePrevious = useCallback( + () => hasFocus && onPrevious?.(), + [hasFocus, onPrevious], + ); + + const handleFirst = useCallback(() => { + hasFocus && onFirst?.(); + }, [hasFocus, onFirst]); + + const handleLast = useCallback( + () => hasFocus && onLast?.(), + [hasFocus, onLast], + ); + + // handler for keyboard navigation + const handleKeySelection = useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + const { key } = event; + + if (mode === 'HORIZONTAL' && key === 'ArrowRight') { + flipLayout ? handlePrevious() : handleNext(); + } else if (mode === 'HORIZONTAL' && key === 'ArrowLeft') { + flipLayout ? handleNext() : handlePrevious(); + } else if ( + (mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING') && + key === 'ArrowDown' + ) { + handleNext(); + } else if ( + (mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING') && + key === 'ArrowUp' + ) { + handlePrevious(); + } else if (key === 'Home') { + handleFirst(); + } else if (key === 'End') { + handleLast(); + } + }, + [handleNext, handlePrevious, handleLast], + ); + + const handleTimelineItemClick = (itemId?: string, isSlideShow?: boolean) => { + if (itemId) { + for (let idx = 0; idx < items.length; idx++) { + if (items[idx].id === itemId) { + if (isSlideShow && idx < items.length - 1) { + onTimelineUpdated?.(idx + 1); + } else { + onTimelineUpdated?.(idx); + } + break; + } + } + + // const selectedItem = items.find((item) => item.id === itemId); + + // if (selectedItem) { + // onItemSelected?.(selectedItem); + // } + } + }; + + useEffect(() => { + const activeItem = items[activeTimelineItem || 0]; + + if (items.length && activeItem) { + // const item = items[activeItem]; + onItemSelected?.(activeItem); + + if (mode === 'HORIZONTAL') { + const card = horizontalContentRef.current?.querySelector( + `#timeline-card-${activeItem.id}`, + ); + + const cardRect = card?.getBoundingClientRect(); + const contentRect = + horizontalContentRef.current?.getBoundingClientRect(); + + if (cardRect && contentRect) { + const { width: cardWidth, left: cardLeft } = cardRect; + const { width: contentWidth, left: contentLeft } = contentRect; + setTimeout(() => { + const ele = horizontalContentRef.current as HTMLElement; + ele.style.scrollBehavior = 'smooth'; + ele.scrollLeft += + cardLeft - contentLeft + cardWidth / 2 - contentWidth / 2; + }, 100); + } + } + } + }, [activeTimelineItem, items.length]); + + const handleScroll = (scroll: Partial<Scroll>) => { + const element = timelineMainRef.current; + if (element) { + setNewOffset(element, scroll); + } + }; + + useEffect(() => { + const ele = timelineMainRef.current; + if (!ele) { + return; + } + if (mode === 'HORIZONTAL') { + ele.scrollLeft = Math.max(newOffSet, 0); + } else { + ele.scrollTop = newOffSet; + } + }, [newOffSet]); + + useEffect(() => { + // setup observer for the timeline elements + setTimeout(() => { + const element = timelineMainRef.current; + + if (element) { + const childElements = element.querySelectorAll('.vertical-item-row'); + Array.from(childElements).forEach((elem) => { + if (observer.current) { + observer.current.observe(elem); + } + }); + } + }, 0); + + const toggleMedia = (elem: HTMLElement, state: string) => { + elem + .querySelectorAll('img,video') + .forEach( + (ele) => + ((ele as HTMLElement).style.visibility = + state === 'hide' ? 'hidden' : 'visible'), + ); + }; + + if (mode !== 'HORIZONTAL') { + observer.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const element = entry.target as HTMLDivElement; + if (entry.isIntersecting) { + // show img and video when visible. + toggleMedia(element, 'show'); + } else { + // hide img and video when not visible. + toggleMedia(element, 'hide'); + // pause YouTube embeds + element.querySelectorAll('iframe').forEach((element) => { + element.contentWindow?.postMessage( + '{"event":"command","func":"stopVideo","args":""}', + '*', + ); + }); + } + }); + }, + { + root: timelineMainRef.current, + threshold: 0, + }, + ); + } + + return () => { + if (observer.current) { + observer.current.disconnect(); + } + }; + // eslint-disable-next-line + }, []); + + const handleKeyDown = useCallback( + (evt: React.KeyboardEvent<HTMLDivElement>) => { + if (!disableNavOnKey && !slideShowRunning) { + setHasFocus(true); + handleKeySelection(evt); + } + }, + [disableNavOnKey, slideShowRunning, handleKeySelection], + ); + + const wrapperClass = useMemo(() => { + return cls(mode.toLocaleLowerCase(), { + 'focus-visible': !isChild, + 'js-focus-visible': !isChild, + }); + }, [mode, isChild]); + + return ( + <Wrapper + // tabIndex={0} + onKeyDown={handleKeyDown} + className={wrapperClass} + cardPositionHorizontal={cardPositionHorizontal} + onMouseDown={() => { + setHasFocus(true); + }} + $hideControls={hideControls} + onKeyUp={(evt) => { + if (evt.key === 'Escape') { + onPaused?.(); + } + }} + > + <TimelineMainWrapper + ref={timelineMainRef} + $scrollable={canScrollTimeline} + className={`${mode.toLowerCase()} timeline-main-wrapper`} + id="timeline-main-wrapper" + theme={theme} + mode={mode} + onScroll={(ev) => { + const target = ev.target as HTMLElement; + let scrolled = 0; + + if (mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING') { + scrolled = target.scrollTop + target.clientHeight; + + if (target.scrollHeight - scrolled < 1) { + onScrollEnd?.(); + } + } else { + scrolled = target.scrollLeft + target.offsetWidth; + + if (target.scrollWidth === scrolled) { + onScrollEnd?.(); + } + } + }} + > + {/* VERTICAL ALTERNATING */} + {timelineMode === 'VERTICAL_ALTERNATING' ? ( + <TimelineVertical + activeTimelineItem={activeTimelineItem} + autoScroll={handleScroll} + contentDetailsChildren={contentDetailsChildren} + hasFocus={hasFocus} + iconChildren={iconChildren} + items={items as TimelineCardModel[]} + mode={timelineMode} + onClick={handleTimelineItemClick} + onElapsed={(itemId?: string) => + handleTimelineItemClick(itemId, true) + } + onOutlineSelection={onOutlineSelection} + slideShowRunning={slideShowRunning} + theme={theme} + enableOutline={enableOutline} + nestedCardHeight={nestedCardHeight} + /> + ) : null} + + {/* HORIZONTAL */} + {timelineMode === 'HORIZONTAL' ? ( + <TimelineMain className={mode.toLowerCase()}> + <Outline color={theme && theme.primary} height={lineWidth} /> + <TimelineHorizontal + autoScroll={handleScroll} + contentDetailsChildren={contentDetailsChildren} + handleItemClick={handleTimelineItemClick} + hasFocus={hasFocus} + iconChildren={iconChildren} + items={items as TimelineCardModel[]} + mode={timelineMode} + onElapsed={(itemId?: string) => + handleTimelineItemClick(itemId, true) + } + slideShowRunning={slideShowRunning} + wrapperId={id.current} + nestedCardHeight={nestedCardHeight} + /> + </TimelineMain> + ) : null} + + {/* VERTICAL */} + {timelineMode === 'VERTICAL' ? ( + <TimelineVertical + activeTimelineItem={activeTimelineItem} + alternateCards={false} + autoScroll={handleScroll} + contentDetailsChildren={contentDetailsChildren} + hasFocus={hasFocus} + iconChildren={iconChildren} + items={items as TimelineCardModel[]} + mode={mode} + onClick={handleTimelineItemClick} + onElapsed={(itemId?: string) => + handleTimelineItemClick(itemId, true) + } + onOutlineSelection={onOutlineSelection} + slideShowRunning={slideShowRunning} + theme={theme} + enableOutline={enableOutline} + nestedCardHeight={nestedCardHeight} + /> + ) : null} + </TimelineMainWrapper> + + {/* Timeline Controls */} + {!hideControls && ( + <TimelineControlContainer mode={mode}> + <TimelineControl + disableLeft={ + flipLayout + ? activeTimelineItem === items.length - 1 + : activeTimelineItem === 0 + } + disableRight={ + flipLayout + ? activeTimelineItem === 0 + : activeTimelineItem === items.length - 1 + } + id={id.current} + onFirst={handleFirst} + onLast={handleLast} + onNext={handleNext} + onPrevious={handlePrevious} + onReplay={onRestartSlideshow} + slideShowEnabled={slideShowEnabled} + slideShowRunning={slideShowRunning} + isDark={darkMode} + onToggleDarkMode={toggleDarkMode} + onPaused={onPaused} + /> + </TimelineControlContainer> + )} + + {/* placeholder to render timeline content for horizontal mode */} + <TimelineContentRender + id={id.current} + $showAllCards={showAllCardsHorizontal} + ref={horizontalContentRef} + /> + </Wrapper> + ); +}; + +Timeline.displayName = 'Timeline'; + +export default Timeline; + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
---|---|---|---|---|---|---|---|---|---|
components | +
+
+ |
+ 19.86% | +29/146 | +100% | +0/0 | +0% | +0/1 | +19.86% | +29/146 | +
components/common/styles | +
+
+ |
+ 100% | +19/19 | +100% | +3/3 | +100% | +2/2 | +100% | +19/19 | +
components/common/test | +
+
+ |
+ 100% | +83/83 | +100% | +1/1 | +14.28% | +1/7 | +100% | +83/83 | +
components/common/themes | +
+
+ |
+ 100% | +41/41 | +100% | +0/0 | +100% | +0/0 | +100% | +41/41 | +
components/effects | +
+
+ |
+ 43.28% | +58/134 | +90% | +9/10 | +33.33% | +1/3 | +43.28% | +58/134 | +
components/icons | +
+
+ |
+ 69.25% | +187/270 | +100% | +8/8 | +57.14% | +8/14 | +69.25% | +187/270 | +
components/timeline | +
+
+ |
+ 25.36% | +156/615 | +100% | +0/0 | +0% | +0/4 | +25.36% | +156/615 | +
components/timeline-elements/memoized | +
+
+ |
+ 95.97% | +167/174 | +80.64% | +25/31 | +33.33% | +1/3 | +95.97% | +167/174 | +
components/timeline-elements/timeline-card | +
+
+ |
+ 96.81% | +334/345 | +62.16% | +23/37 | +91.66% | +11/12 | +96.81% | +334/345 | +
components/timeline-elements/timeline-card-content | +
+
+ |
+ 86.17% | +1041/1208 | +67.58% | +98/145 | +90.32% | +28/31 | +86.17% | +1041/1208 | +
components/timeline-elements/timeline-card-media | +
+
+ |
+ 89.65% | +624/696 | +77.14% | +81/105 | +85.71% | +18/21 | +89.65% | +624/696 | +
components/timeline-elements/timeline-control | +
+
+ |
+ 98% | +294/300 | +42.85% | +18/42 | +100% | +3/3 | +98% | +294/300 | +
components/timeline-elements/timeline-item-title | +
+
+ |
+ 100% | +73/73 | +88.88% | +16/18 | +100% | +6/6 | +100% | +73/73 | +
components/timeline-elements/timeline-outline | +
+
+ |
+ 73.68% | +238/323 | +100% | +1/1 | +0% | +0/9 | +73.68% | +238/323 | +
components/timeline-horizontal | +
+
+ |
+ 47.05% | +64/136 | +100% | +0/0 | +0% | +0/1 | +47.05% | +64/136 | +
components/timeline-vertical | +
+
+ |
+ 80.11% | +548/684 | +55.76% | +29/52 | +83.33% | +10/12 | +80.11% | +548/684 | +
utils | +
+
+ |
+ 100% | +67/67 | +100% | +14/14 | +100% | +6/6 | +100% | +67/67 | +