├── .all-contributorsrc ├── .dccache ├── .deepsource.toml ├── .depcheckrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ ├── coveralls.yml │ ├── cypress.yml │ ├── snyk.yaml │ └── sonar.yml ├── .gitignore ├── .kodiak.toml ├── .prettierignore ├── .prettierrc ├── .restyled.yaml ├── .snyk ├── .stylelintrc ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DARK_MODE_IMPROVEMENTS.md ├── LICENSE ├── PERFORMANCE_IMPROVEMENTS.md ├── README.md ├── babel.config.js ├── browserstack.json ├── coverage ├── base.css ├── block-navigation.js ├── clover.xml ├── components │ ├── common │ │ ├── styles │ │ │ ├── index.html │ │ │ └── index.ts.html │ │ └── themes │ │ │ ├── index.html │ │ │ └── index.ts.html │ ├── effects │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ ├── useClickOutside.test.tsx.html │ │ │ ├── useMatchMedia.test.tsx.html │ │ │ └── useSlideshow.test.ts.html │ │ ├── index.html │ │ ├── useCloseClickOutside.ts.html │ │ ├── useMatchMedia.ts.html │ │ ├── useNewScrollPosition.ts.html │ │ └── useSlideshow.ts.html │ ├── elements │ │ ├── list │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ ├── list-item.test.tsx.html │ │ │ │ └── list.test.tsx.html │ │ │ ├── index.html │ │ │ ├── list-item.tsx.html │ │ │ ├── list.model.ts.html │ │ │ ├── list.styles.ts.html │ │ │ └── list.tsx.html │ │ └── popover │ │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ └── popover.test.tsx.html │ │ │ ├── index.html │ │ │ ├── index.tsx.html │ │ │ ├── popover.model.ts.html │ │ │ └── popover.styles.ts.html │ ├── timeline-elements │ │ ├── memoized │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── index.test.tsx.html │ │ │ ├── details-text-memo.tsx.html │ │ │ ├── expand-button-memo.tsx.html │ │ │ ├── index.html │ │ │ ├── memoized-model.ts.html │ │ │ ├── show-hide-button.tsx.html │ │ │ ├── subtitle-memo.tsx.html │ │ │ └── title-memo.tsx.html │ │ ├── timeline-card-content │ │ │ ├── __tests__ │ │ │ │ ├── content-footer.test.tsx.html │ │ │ │ ├── content-header.test.tsx.html │ │ │ │ ├── details-text.test.tsx.html │ │ │ │ ├── index.html │ │ │ │ ├── text_or_content.test.tsx.html │ │ │ │ └── timeline-card-content.test.tsx.html │ │ │ ├── card-animations.styles.ts.html │ │ │ ├── content-footer.tsx.html │ │ │ ├── content-header.tsx.html │ │ │ ├── details-text.model.ts.html │ │ │ ├── details-text.tsx.html │ │ │ ├── header-footer.model.ts.html │ │ │ ├── index.html │ │ │ ├── text-or-content.tsx.html │ │ │ ├── timeline-card-content.styles.ts.html │ │ │ └── timeline-card-content.tsx.html │ │ ├── timeline-card-media │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── timeline-card-media.test.tsx.html │ │ │ ├── components │ │ │ │ ├── ContentDisplay.tsx.html │ │ │ │ ├── ErrorMessage.tsx.html │ │ │ │ ├── ImageDisplay.tsx.html │ │ │ │ ├── MediaContent.tsx.html │ │ │ │ ├── VideoPlayer.tsx.html │ │ │ │ ├── YoutubePlayer.tsx.html │ │ │ │ └── index.html │ │ │ ├── hooks │ │ │ │ ├── index.html │ │ │ │ ├── useMediaLoad.ts.html │ │ │ │ ├── useToggleControls.ts.html │ │ │ │ ├── useViewOptions.ts.html │ │ │ │ └── useYouTubeDetection.ts.html │ │ │ ├── index.html │ │ │ ├── timeline-card-media-buttons.tsx.html │ │ │ ├── timeline-card-media.styles.ts.html │ │ │ ├── timeline-card-media.tsx.html │ │ │ └── video.tsx.html │ │ ├── timeline-card │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── timeline-horizontal-card.test.tsx.html │ │ │ ├── hooks │ │ │ │ ├── index.html │ │ │ │ └── useTimelineCard.ts.html │ │ │ ├── index.html │ │ │ ├── timeline-card-portal │ │ │ │ ├── index.html │ │ │ │ └── timeline-card-portal.tsx.html │ │ │ ├── timeline-horizontal-card.styles.ts.html │ │ │ ├── timeline-horizontal-card.tsx.html │ │ │ └── timeline-point │ │ │ │ ├── index.html │ │ │ │ └── timeline-point.tsx.html │ │ ├── timeline-control │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── timeline-control.test.tsx.html │ │ │ ├── index.html │ │ │ ├── timeline-control.styles.ts.html │ │ │ ├── timeline-control.styles.tsx.html │ │ │ └── timeline-control.tsx.html │ │ ├── timeline-item-title │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── timeline-card-title.test.tsx.html │ │ │ ├── index.html │ │ │ ├── timeline-card-title.styles.ts.html │ │ │ └── timeline-card-title.tsx.html │ │ └── timeline-outline │ │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ └── timeline-outline.test.tsx.html │ │ │ ├── animations.ts.html │ │ │ ├── hooks │ │ │ ├── index.html │ │ │ └── useOutlinePosition.ts.html │ │ │ ├── index.html │ │ │ ├── timeline-outline-item-list.tsx.html │ │ │ ├── timeline-outline.model.ts.html │ │ │ ├── timeline-outline.styles.ts.html │ │ │ └── timeline-outline.tsx.html │ ├── timeline-horizontal │ │ ├── index.html │ │ ├── timeline-horizontal.styles.ts.html │ │ └── timeline-horizontal.tsx.html │ ├── timeline-vertical │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ ├── timeline-point.test.tsx.html │ │ │ └── timeline-vertical-item.test.tsx.html │ │ ├── index.html │ │ ├── timeline-point.tsx.html │ │ ├── timeline-vertical-item.tsx.html │ │ ├── timeline-vertical-shape.styles.ts.html │ │ ├── timeline-vertical.styles.ts.html │ │ └── timeline-vertical.tsx.html │ ├── timeline │ │ ├── TimelineView.tsx.html │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ ├── layout-switcher.test.tsx.html │ │ │ └── timeline.test.tsx.html │ │ ├── index.html │ │ ├── timeline-popover-elements.tsx.html │ │ ├── timeline-popover.model.ts.html │ │ ├── timeline-toolbar.model.ts.html │ │ ├── timeline-toolbar.tsx.html │ │ ├── timeline.style.ts.html │ │ ├── timeline.style.tsx.html │ │ └── timeline.tsx.html │ ├── toggle-button │ │ ├── index.html │ │ ├── index.tsx.html │ │ └── toggle-button.styles.ts.html │ └── toolbar │ │ ├── __tests__ │ │ ├── index.html │ │ └── toolbar.test.tsx.html │ │ ├── index.html │ │ ├── index.tsx.html │ │ └── toolbar.styles.ts.html ├── favicon.png ├── hooks │ ├── __tests__ │ │ ├── index.html │ │ ├── useBackground.test.ts.html │ │ ├── useCardSize.test.ts.html │ │ ├── useEscapeKey.test.ts.html │ │ ├── useMediaState.test.ts.html │ │ ├── useOutsideClick.test.ts.html │ │ ├── useTimelineMedia.test.ts.html │ │ ├── useTimelineMode.test.ts.html │ │ ├── useTimelineNavigation.test.ts.html │ │ ├── useTimelineScroll.test.ts.html │ │ ├── useTimelineSearch.test.ts.html │ │ ├── useUIState.test.ts.html │ │ └── useWindowSize.test.ts.html │ ├── index.html │ ├── useBackground.ts.html │ ├── useCardSize.ts.html │ ├── useEscapeKey.ts.html │ ├── useMediaState.ts.html │ ├── useOutsideClick.ts.html │ ├── useTimelineMedia.ts.html │ ├── useTimelineMode.ts.html │ ├── useTimelineNavigation.ts.html │ ├── useTimelineScroll.ts.html │ ├── useTimelineSearch.ts.html │ ├── useUIState.ts.html │ └── useWindowSize.ts.html ├── index.html ├── lcov-report │ ├── base.css │ ├── block-navigation.js │ ├── components │ │ ├── common │ │ │ ├── styles │ │ │ │ ├── index.html │ │ │ │ └── index.ts.html │ │ │ └── themes │ │ │ │ ├── index.html │ │ │ │ └── index.ts.html │ │ ├── effects │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ ├── useClickOutside.test.tsx.html │ │ │ │ ├── useMatchMedia.test.tsx.html │ │ │ │ └── useSlideshow.test.ts.html │ │ │ ├── index.html │ │ │ ├── useCloseClickOutside.ts.html │ │ │ ├── useMatchMedia.ts.html │ │ │ ├── useNewScrollPosition.ts.html │ │ │ └── useSlideshow.ts.html │ │ ├── elements │ │ │ ├── list │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.html │ │ │ │ │ ├── list-item.test.tsx.html │ │ │ │ │ └── list.test.tsx.html │ │ │ │ ├── index.html │ │ │ │ ├── list-item.tsx.html │ │ │ │ ├── list.model.ts.html │ │ │ │ ├── list.styles.ts.html │ │ │ │ └── list.tsx.html │ │ │ └── popover │ │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── popover.test.tsx.html │ │ │ │ ├── index.html │ │ │ │ ├── index.tsx.html │ │ │ │ ├── popover.model.ts.html │ │ │ │ └── popover.styles.ts.html │ │ ├── timeline-elements │ │ │ ├── memoized │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.html │ │ │ │ │ └── index.test.tsx.html │ │ │ │ ├── details-text-memo.tsx.html │ │ │ │ ├── expand-button-memo.tsx.html │ │ │ │ ├── index.html │ │ │ │ ├── memoized-model.ts.html │ │ │ │ ├── show-hide-button.tsx.html │ │ │ │ ├── subtitle-memo.tsx.html │ │ │ │ └── title-memo.tsx.html │ │ │ ├── timeline-card-content │ │ │ │ ├── __tests__ │ │ │ │ │ ├── content-footer.test.tsx.html │ │ │ │ │ ├── content-header.test.tsx.html │ │ │ │ │ ├── details-text.test.tsx.html │ │ │ │ │ ├── index.html │ │ │ │ │ ├── text_or_content.test.tsx.html │ │ │ │ │ └── timeline-card-content.test.tsx.html │ │ │ │ ├── card-animations.styles.ts.html │ │ │ │ ├── content-footer.tsx.html │ │ │ │ ├── content-header.tsx.html │ │ │ │ ├── details-text.model.ts.html │ │ │ │ ├── details-text.tsx.html │ │ │ │ ├── header-footer.model.ts.html │ │ │ │ ├── index.html │ │ │ │ ├── text-or-content.tsx.html │ │ │ │ ├── timeline-card-content.styles.ts.html │ │ │ │ └── timeline-card-content.tsx.html │ │ │ ├── timeline-card-media │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.html │ │ │ │ │ └── timeline-card-media.test.tsx.html │ │ │ │ ├── components │ │ │ │ │ ├── ContentDisplay.tsx.html │ │ │ │ │ ├── ErrorMessage.tsx.html │ │ │ │ │ ├── ImageDisplay.tsx.html │ │ │ │ │ ├── MediaContent.tsx.html │ │ │ │ │ ├── VideoPlayer.tsx.html │ │ │ │ │ ├── YoutubePlayer.tsx.html │ │ │ │ │ └── index.html │ │ │ │ ├── hooks │ │ │ │ │ ├── index.html │ │ │ │ │ ├── useMediaLoad.ts.html │ │ │ │ │ ├── useToggleControls.ts.html │ │ │ │ │ ├── useViewOptions.ts.html │ │ │ │ │ └── useYouTubeDetection.ts.html │ │ │ │ ├── index.html │ │ │ │ ├── timeline-card-media-buttons.tsx.html │ │ │ │ ├── timeline-card-media.styles.ts.html │ │ │ │ ├── timeline-card-media.tsx.html │ │ │ │ └── video.tsx.html │ │ │ ├── timeline-card │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.html │ │ │ │ │ └── timeline-horizontal-card.test.tsx.html │ │ │ │ ├── hooks │ │ │ │ │ ├── index.html │ │ │ │ │ └── useTimelineCard.ts.html │ │ │ │ ├── index.html │ │ │ │ ├── timeline-card-portal │ │ │ │ │ ├── index.html │ │ │ │ │ └── timeline-card-portal.tsx.html │ │ │ │ ├── timeline-horizontal-card.styles.ts.html │ │ │ │ ├── timeline-horizontal-card.tsx.html │ │ │ │ └── timeline-point │ │ │ │ │ ├── index.html │ │ │ │ │ └── timeline-point.tsx.html │ │ │ ├── timeline-control │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.html │ │ │ │ │ └── timeline-control.test.tsx.html │ │ │ │ ├── index.html │ │ │ │ ├── timeline-control.styles.ts.html │ │ │ │ ├── timeline-control.styles.tsx.html │ │ │ │ └── timeline-control.tsx.html │ │ │ ├── timeline-item-title │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.html │ │ │ │ │ └── timeline-card-title.test.tsx.html │ │ │ │ ├── index.html │ │ │ │ ├── timeline-card-title.styles.ts.html │ │ │ │ └── timeline-card-title.tsx.html │ │ │ └── timeline-outline │ │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ └── timeline-outline.test.tsx.html │ │ │ │ ├── animations.ts.html │ │ │ │ ├── hooks │ │ │ │ ├── index.html │ │ │ │ └── useOutlinePosition.ts.html │ │ │ │ ├── index.html │ │ │ │ ├── timeline-outline-item-list.tsx.html │ │ │ │ ├── timeline-outline.model.ts.html │ │ │ │ ├── timeline-outline.styles.ts.html │ │ │ │ └── timeline-outline.tsx.html │ │ ├── timeline-horizontal │ │ │ ├── index.html │ │ │ ├── timeline-horizontal.styles.ts.html │ │ │ └── timeline-horizontal.tsx.html │ │ ├── timeline-vertical │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ ├── timeline-point.test.tsx.html │ │ │ │ └── timeline-vertical-item.test.tsx.html │ │ │ ├── index.html │ │ │ ├── timeline-point.tsx.html │ │ │ ├── timeline-vertical-item.tsx.html │ │ │ ├── timeline-vertical-shape.styles.ts.html │ │ │ ├── timeline-vertical.styles.ts.html │ │ │ └── timeline-vertical.tsx.html │ │ ├── timeline │ │ │ ├── TimelineView.tsx.html │ │ │ ├── __tests__ │ │ │ │ ├── index.html │ │ │ │ ├── layout-switcher.test.tsx.html │ │ │ │ └── timeline.test.tsx.html │ │ │ ├── index.html │ │ │ ├── timeline-popover-elements.tsx.html │ │ │ ├── timeline-popover.model.ts.html │ │ │ ├── timeline-toolbar.model.ts.html │ │ │ ├── timeline-toolbar.tsx.html │ │ │ ├── timeline.style.ts.html │ │ │ ├── timeline.style.tsx.html │ │ │ └── timeline.tsx.html │ │ ├── toggle-button │ │ │ ├── index.html │ │ │ ├── index.tsx.html │ │ │ └── toggle-button.styles.ts.html │ │ └── toolbar │ │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ └── toolbar.test.tsx.html │ │ │ ├── index.html │ │ │ ├── index.tsx.html │ │ │ └── toolbar.styles.ts.html │ ├── favicon.png │ ├── hooks │ │ ├── __tests__ │ │ │ ├── index.html │ │ │ ├── useBackground.test.ts.html │ │ │ ├── useCardSize.test.ts.html │ │ │ ├── useEscapeKey.test.ts.html │ │ │ ├── useMediaState.test.ts.html │ │ │ ├── useOutsideClick.test.ts.html │ │ │ ├── useTimelineMedia.test.ts.html │ │ │ ├── useTimelineMode.test.ts.html │ │ │ ├── useTimelineNavigation.test.ts.html │ │ │ ├── useTimelineScroll.test.ts.html │ │ │ ├── useTimelineSearch.test.ts.html │ │ │ ├── useUIState.test.ts.html │ │ │ └── useWindowSize.test.ts.html │ │ ├── index.html │ │ ├── useBackground.ts.html │ │ ├── useCardSize.ts.html │ │ ├── useEscapeKey.ts.html │ │ ├── useMediaState.ts.html │ │ ├── useOutsideClick.ts.html │ │ ├── useTimelineMedia.ts.html │ │ ├── useTimelineMode.ts.html │ │ ├── useTimelineNavigation.ts.html │ │ ├── useTimelineScroll.ts.html │ │ ├── useTimelineSearch.ts.html │ │ ├── useUIState.ts.html │ │ └── useWindowSize.ts.html │ ├── index.html │ ├── prettify.css │ ├── prettify.js │ ├── react-chrono │ │ └── src │ │ │ └── hooks │ │ │ └── __tests__ │ │ │ ├── index.html │ │ │ ├── useBackground.test.ts.html │ │ │ ├── useCardSize.test.ts.html │ │ │ ├── useEscapeKey.test.ts.html │ │ │ ├── useMediaState.test.ts.html │ │ │ ├── useOutsideClick.test.ts.html │ │ │ ├── useTimelineMedia.test.ts.html │ │ │ ├── useTimelineMode.test.ts.html │ │ │ ├── useTimelineNavigation.test.ts.html │ │ │ ├── useTimelineScroll.test.ts.html │ │ │ ├── useTimelineSearch.test.ts.html │ │ │ ├── useUIState.test.ts.html │ │ │ └── useWindowSize.test.ts.html │ ├── sort-arrow-sprite.png │ ├── sorter.js │ └── utils │ │ ├── index.html │ │ ├── index.ts.html │ │ ├── mediaQueryUtils.ts.html │ │ ├── timelineUtils.ts.html │ │ └── utils.test.ts.html ├── lcov.info ├── prettify.css ├── prettify.js ├── react-chrono │ └── src │ │ └── hooks │ │ └── __tests__ │ │ ├── index.html │ │ ├── useBackground.test.ts.html │ │ ├── useCardSize.test.ts.html │ │ ├── useEscapeKey.test.ts.html │ │ ├── useMediaState.test.ts.html │ │ ├── useOutsideClick.test.ts.html │ │ ├── useTimelineMedia.test.ts.html │ │ ├── useTimelineMode.test.ts.html │ │ ├── useTimelineNavigation.test.ts.html │ │ ├── useTimelineScroll.test.ts.html │ │ ├── useTimelineSearch.test.ts.html │ │ ├── useUIState.test.ts.html │ │ └── useWindowSize.test.ts.html ├── sort-arrow-sprite.png ├── sorter.js └── utils │ ├── index.html │ ├── index.ts.html │ ├── mediaQueryUtils.ts.html │ ├── timelineUtils.ts.html │ └── utils.test.ts.html ├── cypress.config.ts ├── cypress ├── e2e │ ├── examples │ │ ├── actions.spec.js │ │ ├── aliasing.spec.js │ │ ├── assertions.spec.js │ │ ├── connectors.spec.js │ │ ├── cookies.spec.js │ │ ├── cypress_api.spec.js │ │ ├── files.spec.js │ │ ├── local_storage.spec.js │ │ ├── location.spec.js │ │ ├── misc.spec.js │ │ ├── navigation.spec.js │ │ ├── network_requests.spec.js │ │ ├── querying.spec.js │ │ ├── spies_stubs_clocks.spec.js │ │ ├── traversal.spec.js │ │ ├── utilities.spec.js │ │ ├── viewport.spec.js │ │ ├── waiting.spec.js │ │ └── window.spec.js │ └── react-chrono │ │ ├── horizontal_spec.js │ │ ├── vertical_alternating_nomedia.js │ │ ├── vertical_alternating_spec.js │ │ └── vertical_spec.js ├── fixtures │ ├── example.json │ ├── profile.json │ └── users.json ├── plugins │ └── index.js ├── support │ ├── commands.js │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ └── e2e.js └── tsconfig.json ├── eslint.config.mjs ├── index.html ├── open_issues_last_1_year.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── camera.svg ├── color-circle.svg ├── d-day.mp4 ├── dunkirk.mp4 ├── favicon.ico ├── github copy.svg ├── github.svg ├── grid.svg ├── list.svg ├── manifest.json ├── notification-bell.svg ├── operation-barbarasso.mp4 ├── pearl-harbor.mp4 ├── robots.txt ├── rss.svg ├── satellite-dish.svg ├── zap.svg └── zapold.svg ├── readme-assets ├── horizontal_all.jpg ├── social-logo-small.png ├── text_overlay.jpg ├── vertical_alternating.jpg └── vertical_basic.jpg ├── rollup.config.mjs ├── sonar-project.properties ├── src ├── components │ ├── GlobalContext.tsx │ ├── __tests__ │ │ └── GlobalContext.test.tsx │ ├── common │ │ ├── styles │ │ │ └── index.ts │ │ ├── test │ │ │ └── index.tsx │ │ └── themes │ │ │ └── index.ts │ ├── contexts │ │ ├── DynamicContext.tsx │ │ ├── OptimizedContextProvider.tsx │ │ ├── StableContext.tsx │ │ ├── hooks.tsx │ │ ├── index.tsx │ │ └── legacy-types.tsx │ ├── effects │ │ ├── __tests__ │ │ │ ├── useClickOutside.test.tsx │ │ │ ├── useMatchMedia.test.tsx │ │ │ └── useSlideshow.test.ts │ │ ├── useCloseClickOutside.ts │ │ ├── useMatchMedia.ts │ │ ├── useNewScrollPosition.ts │ │ └── useSlideshow.ts │ ├── elements │ │ ├── list │ │ │ ├── __tests__ │ │ │ │ ├── list-item.test.tsx │ │ │ │ └── list.test.tsx │ │ │ ├── list-item.tsx │ │ │ ├── list.model.ts │ │ │ ├── list.styles.ts │ │ │ └── list.tsx │ │ └── popover │ │ │ ├── __tests__ │ │ │ └── popover.test.tsx │ │ │ ├── index.tsx │ │ │ ├── popover.model.ts │ │ │ └── popover.styles.ts │ ├── icons │ │ ├── arrow-down.tsx │ │ ├── check.tsx │ │ ├── chev-down.tsx │ │ ├── chev-left.tsx │ │ ├── chev-right.tsx │ │ ├── chev-up.tsx │ │ ├── chevs-left.tsx │ │ ├── chevs-right.tsx │ │ ├── close.tsx │ │ ├── index.tsx │ │ ├── layout.tsx │ │ ├── maximize.tsx │ │ ├── menu.tsx │ │ ├── minimize.tsx │ │ ├── minus.tsx │ │ ├── moon.tsx │ │ ├── para.tsx │ │ ├── plus.tsx │ │ ├── replay-icon.tsx │ │ ├── stop.tsx │ │ ├── sun.tsx │ │ ├── text.tsx │ │ └── triangle-right.tsx │ ├── index.tsx │ ├── timeline-elements │ │ ├── memoized │ │ │ ├── __tests__ │ │ │ │ └── index.test.tsx │ │ │ ├── details-text-memo.tsx │ │ │ ├── expand-button-memo.tsx │ │ │ ├── memoized-model.ts │ │ │ ├── show-hide-button.tsx │ │ │ ├── subtitle-memo.tsx │ │ │ └── title-memo.tsx │ │ ├── nested-timeline-renderer │ │ │ └── nested-timeline-renderer.tsx │ │ ├── timeline-card-content │ │ │ ├── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ ├── content-footer.test.tsx.snap │ │ │ │ │ └── content-header.test.tsx.snap │ │ │ │ ├── content-footer.test.tsx │ │ │ │ ├── content-header.test.tsx │ │ │ │ ├── details-text.test.tsx │ │ │ │ ├── text_or_content.test.tsx │ │ │ │ └── timeline-card-content.test.tsx │ │ │ ├── card-animations.styles.ts │ │ │ ├── content-footer.tsx │ │ │ ├── content-header.tsx │ │ │ ├── details-text.model.ts │ │ │ ├── details-text.tsx │ │ │ ├── header-footer.model.ts │ │ │ ├── text-or-content.tsx │ │ │ ├── timeline-card-content.styles.ts │ │ │ └── timeline-card-content.tsx │ │ ├── timeline-card-media │ │ │ ├── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── timeline-card-media.test.tsx.snap │ │ │ │ └── timeline-card-media.test.tsx │ │ │ ├── components │ │ │ │ ├── ContentDisplay.tsx │ │ │ │ ├── ErrorMessage.tsx │ │ │ │ ├── ImageDisplay.tsx │ │ │ │ ├── MediaContent.tsx │ │ │ │ ├── VideoPlayer.tsx │ │ │ │ └── YoutubePlayer.tsx │ │ │ ├── hooks │ │ │ │ ├── useMediaLoad.ts │ │ │ │ ├── useToggleControls.ts │ │ │ │ ├── useViewOptions.ts │ │ │ │ └── useYouTubeDetection.ts │ │ │ ├── timeline-card-media-buttons.tsx │ │ │ ├── timeline-card-media.styles.ts │ │ │ ├── timeline-card-media.tsx │ │ │ └── video.tsx │ │ ├── timeline-card │ │ │ ├── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── timeline-horizontal-card.test.tsx.snap │ │ │ │ └── timeline-horizontal-card.test.tsx │ │ │ ├── hooks │ │ │ │ └── useTimelineCard.ts │ │ │ ├── timeline-card-portal │ │ │ │ └── timeline-card-portal.tsx │ │ │ ├── timeline-horizontal-card.styles.ts │ │ │ ├── timeline-horizontal-card.tsx │ │ │ └── timeline-point │ │ │ │ └── timeline-point.tsx │ │ ├── timeline-control │ │ │ ├── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── timeline-control.test.tsx.snap │ │ │ │ └── timeline-control.test.tsx │ │ │ ├── timeline-control.styles.ts │ │ │ ├── timeline-control.styles.tsx │ │ │ └── timeline-control.tsx │ │ ├── timeline-item-title │ │ │ ├── __tests__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── timeline-card-title.test.tsx.snap │ │ │ │ └── timeline-card-title.test.tsx │ │ │ ├── timeline-card-title.styles.ts │ │ │ └── timeline-card-title.tsx │ │ └── timeline-outline │ │ │ ├── __tests__ │ │ │ └── timeline-outline.test.tsx │ │ │ ├── animations.ts │ │ │ ├── hooks │ │ │ └── useOutlinePosition.ts │ │ │ ├── timeline-outline-item-list.tsx │ │ │ ├── timeline-outline.model.ts │ │ │ ├── timeline-outline.styles.ts │ │ │ └── timeline-outline.tsx │ ├── timeline-horizontal │ │ ├── timeline-horizontal.styles.ts │ │ └── timeline-horizontal.tsx │ ├── timeline-vertical │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── timeline-point.test.tsx.snap │ │ │ │ └── timeline-vertical-item.test.tsx.snap │ │ │ ├── timeline-point.test.tsx │ │ │ └── timeline-vertical-item.test.tsx │ │ ├── timeline-point.tsx │ │ ├── timeline-vertical-item.tsx │ │ ├── timeline-vertical-shape.styles.ts │ │ ├── timeline-vertical.styles.ts │ │ └── timeline-vertical.tsx │ ├── timeline │ │ ├── TimelineView.tsx │ │ ├── __tests__ │ │ │ ├── layout-switcher.test.tsx │ │ │ └── timeline.test.tsx │ │ ├── timeline-popover-elements.tsx │ │ ├── timeline-popover.model.ts │ │ ├── timeline-toolbar.model.ts │ │ ├── timeline-toolbar.tsx │ │ ├── timeline.style.ts │ │ ├── timeline.style.tsx │ │ └── timeline.tsx │ ├── toggle-button │ │ ├── index.tsx │ │ └── toggle-button.styles.ts │ └── toolbar │ │ ├── __tests__ │ │ └── toolbar.test.tsx │ │ ├── index.tsx │ │ └── toolbar.styles.ts ├── demo │ ├── App.css │ ├── App.styles.ts │ ├── App.tsx │ ├── README.md │ ├── components │ │ ├── ThemeShowcase.tsx │ │ ├── horizontal │ │ │ ├── AllHorizontal.tsx │ │ │ ├── BasicHorizontal.tsx │ │ │ ├── CardlessHorizontal.tsx │ │ │ ├── InitialSelectedHorizontal.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ └── vertical │ │ │ ├── AlternatingNestedVertical.tsx │ │ │ ├── AlternatingVertical.tsx │ │ │ ├── BasicVertical.tsx │ │ │ ├── CardlessVertical.tsx │ │ │ ├── CustomContentVertical.tsx │ │ │ ├── CustomContentWithIconsVertical.tsx │ │ │ ├── MixedVertical.tsx │ │ │ ├── NestedVertical.tsx │ │ │ ├── NewMediaVertical.tsx │ │ │ └── index.ts │ ├── custom-theme-example.tsx │ ├── data │ │ ├── basic-timeline.ts │ │ ├── index.ts │ │ ├── mixed-timeline.ts │ │ ├── nested-timeline.ts │ │ └── world-history.ts │ ├── dynamic-load.tsx │ ├── layout.module.scss │ └── layout.tsx ├── examples │ ├── basic-horizontal.tsx │ ├── basic-slideshow.tsx │ ├── basic-vertical.tsx │ ├── data.ts │ └── slideshow-with-overall-progress.tsx ├── hooks │ ├── __tests__ │ │ ├── useBackground.test.ts │ │ ├── useCardSize.test.ts │ │ ├── useEscapeKey.test.ts │ │ ├── useMediaState.test.ts │ │ ├── useOutsideClick.test.ts │ │ ├── useTimelineMedia.test.ts │ │ ├── useTimelineMode.test.ts │ │ ├── useTimelineNavigation.test.ts │ │ ├── useTimelineScroll.test.ts │ │ ├── useTimelineSearch.test.ts │ │ ├── useUIState.test.ts │ │ └── useWindowSize.test.ts │ ├── useBackground.ts │ ├── useCardSize.ts │ ├── useEscapeKey.ts │ ├── useMediaState.ts │ ├── useOutsideClick.ts │ ├── useSlideshowProgress.ts │ ├── useTimelineMedia.ts │ ├── useTimelineMode.ts │ ├── useTimelineNavigation.ts │ ├── useTimelineScroll.ts │ ├── useTimelineSearch.ts │ ├── useUIState.ts │ └── useWindowSize.ts ├── index.css ├── index.tsx ├── models │ ├── Theme.ts │ ├── TimelineCardTitleModel.ts │ ├── TimelineContentModel.ts │ ├── TimelineControlModel.ts │ ├── TimelineHorizontalModel.ts │ ├── TimelineItemModel.ts │ ├── TimelineMediaModel.ts │ ├── TimelineModel.ts │ ├── TimelineVerticalModel.ts │ ├── ToolbarItem.ts │ └── ToolbarProps.ts ├── react-app-env.d.ts ├── react-chrono.ts ├── test-setup.js ├── types │ └── global.d.ts └── utils │ ├── index.ts │ ├── mediaQueryUtils.test.ts │ ├── mediaQueryUtils.ts │ ├── performance.test.ts │ ├── performance.ts │ ├── timelineUtils.test.ts │ ├── timelineUtils.ts │ └── utils.test.ts ├── stats.html ├── tailwind.config.js ├── tsconfig.json ├── types └── static.d.ts ├── vite.config.mts ├── vitest.config.mts └── webpack.config.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 60, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "aloisdg", 10 | "name": "Alois", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/3449303?v=4", 12 | "profile": "http://aloisdg.github.io/", 13 | "contributions": [ 14 | "doc" 15 | ] 16 | }, 17 | { 18 | "login": "koji", 19 | "name": "Koji", 20 | "avatar_url": "https://avatars.githubusercontent.com/u/474225?v=4", 21 | "profile": "https://kojikoji.ga", 22 | "contributions": [ 23 | "doc" 24 | ] 25 | }, 26 | { 27 | "login": "alx", 28 | "name": "Alexandre Girard", 29 | "avatar_url": "https://avatars.githubusercontent.com/u/373?v=4", 30 | "profile": "http://alexgirard.com", 31 | "contributions": [ 32 | "code" 33 | ] 34 | }, 35 | { 36 | "login": "Ryuyxx", 37 | "name": "Ryuya", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/69892552?v=4", 39 | "profile": "https://github.com/Ryuyxx", 40 | "contributions": [ 41 | "doc" 42 | ] 43 | }, 44 | { 45 | "login": "taqi457", 46 | "name": "Taqi Abbas", 47 | "avatar_url": "https://avatars.githubusercontent.com/u/2155219?v=4", 48 | "profile": "https://github.com/taqi457", 49 | "contributions": [ 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "megasoft78", 55 | "name": "megasoft78", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/514958?v=4", 57 | "profile": "https://github.com/megasoft78", 58 | "contributions": [ 59 | "code" 60 | ] 61 | }, 62 | { 63 | "login": "bigbigDreamer", 64 | "name": "Eric(书生)", 65 | "avatar_url": "https://avatars.githubusercontent.com/u/39019913?v=4", 66 | "profile": "https://dev.bigdreamer.cc", 67 | "contributions": [ 68 | "code" 69 | ] 70 | }, 71 | { 72 | "login": "DrakeXorn", 73 | "name": "Christophe Bernard", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/3925261?v=4", 75 | "profile": "https://github.com/DrakeXorn", 76 | "contributions": [ 77 | "code" 78 | ] 79 | } 80 | ], 81 | "contributorsPerLine": 7, 82 | "projectName": "react-chrono", 83 | "projectOwner": "prabhuignoto", 84 | "repoType": "github", 85 | "repoHost": "https://github.com", 86 | "skipCi": true, 87 | "commitConvention": "angular" 88 | } 89 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | exclude_patterns = [ 4 | "**/node_modules/", 5 | "dist/**", 6 | "src/examples/**", 7 | "src/demo/**" 8 | ] 9 | 10 | [[analyzers]] 11 | name = "javascript" 12 | enabled = true 13 | 14 | [analyzers.meta] 15 | environment = ["browser"] 16 | plugins = ["react"] 17 | style_guide = "standard" 18 | dialect = "typescript" 19 | -------------------------------------------------------------------------------- /.depcheckrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignores": [ 3 | "eslint", 4 | "babel-*", 5 | "@rollup/*", 6 | "react", 7 | "react-dom", 8 | "rollup-plugin-*" 9 | ], 10 | "skip-missing": true, 11 | "ignore-bin-package": false, 12 | "ignore-dirs": [ 13 | "dist", 14 | "coverage" 15 | ], 16 | "ignore-path": ".eslintignore", 17 | "parsers": { 18 | "*.js": "es6", 19 | "*.jsx": "jsx", 20 | "*.ts": "typescript", 21 | "*.tsx": "tsx" 22 | }, 23 | "specials": [ 24 | "bin", 25 | "eslint", 26 | "mocha" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: prabhuignoto 3 | ko_fi: prabhuignoto 4 | buy_me_a_coffee: prabhuignoto 5 | custom: ['paypal.me/prabhuignoto'] 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEAT] Brief description of the feature' 5 | labels: 'enhancement, needs-discussion' 6 | assignees: '' 7 | --- 8 | 9 | **Before You Submit** 10 | 11 | - [ ] Please search for existing feature requests (open and closed) to avoid creating a duplicate. 12 | - [ ] Ensure this feature aligns with the project's overall goals and scope (if known). 13 | 14 | **1. The Problem or User Story** 15 | _Is your feature request related to a problem? Or is it a new idea?_ 16 | 17 | - Please describe the problem this feature would solve OR provide a user story. 18 | - **User Story Example:** "As a [type of user], I want to [perform an action] so that I can [achieve this benefit/goal]." 19 | - **2. Proposed Solution** 20 | _Describe the solution you'd like in detail._ 21 | 22 | - How would this feature work? 23 | - What would it look like? (Feel free to include mockups, diagrams, or detailed descriptions in the "Additional Context" section). 24 | - **3. Value & Benefits** 25 | _Why is this feature important? What value would it bring?_ 26 | 27 | - Who would benefit from this feature? 28 | - How would it improve the project or user experience? 29 | - **4. Alternatives Considered (Optional)** 30 | _Have you thought of other ways to address the problem or achieve the goal?_ 31 | 32 | - A clear and concise description of any alternative solutions or features you've considered. 33 | - Why is your proposed solution the best fit? 34 | - **5. Success Metrics (Optional)** 35 | _How would we know if this feature is successful after implementation?_ 36 | 37 | - e.g., Increased user engagement in X area, Reduced time to complete Y task, Positive feedback on Z aspect. 38 | - **6. Accessibility Considerations (if applicable)** 39 | _Please describe any accessibility aspects related to this feature._ 40 | 41 | - Will this feature be accessible to users with disabilities (e.g., screen reader compatibility, keyboard navigation, color contrast)? 42 | - **7. Additional Context** 43 | _Add any other context, screenshots, mockups, or links about the feature request here._ 44 | 45 | - 46 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x] 16 | 17 | steps: 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | version: 8.8.0 21 | 22 | - uses: actions/checkout@v4 23 | 24 | - name: Cache PNPM dependencies 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.pnpm-store 28 | key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pnpm- 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | 37 | - name: Install Dependencies 38 | run: pnpm install 39 | 40 | - name: Cache build artifacts 41 | uses: actions/cache@v4 42 | with: 43 | path: dist 44 | key: ${{ runner.os }}-build-${{ matrix.node-version }} 45 | restore-keys: | 46 | ${{ runner.os }}-build- 47 | 48 | - name: Build 49 | run: pnpm rollup 50 | 51 | - name: Unit Tests 52 | run: pnpm test 53 | -------------------------------------------------------------------------------- /.github/workflows/coveralls.yml: -------------------------------------------------------------------------------- 1 | name: Coveralls 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | upload: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2.4.2 14 | 15 | - name: Cache PNPM dependencies 16 | uses: actions/cache@v4 17 | with: 18 | path: ~/.pnpm-store 19 | key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} 20 | restore-keys: | 21 | ${{ runner.os }}-pnpm- 22 | 23 | - name: Set up pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: latest 27 | 28 | - name: Install dependencies 29 | run: pnpm install 30 | 31 | - name: Run tests 32 | run: pnpm test 33 | 34 | - name: Upload coverage report 35 | env: 36 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_SECRET }} 37 | run: pnpm run coveralls 38 | -------------------------------------------------------------------------------- /.github/workflows/snyk.yaml: -------------------------------------------------------------------------------- 1 | name: Snyk Security Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | snyk-security-scan: 13 | runs-on: ubuntu-latest 14 | name: Snyk Security Scan 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup PNPM 19 | uses: pnpm/action-setup@v4 20 | with: 21 | version: latest 22 | 23 | - name: Cache PNPM dependencies 24 | uses: actions/cache@v4 25 | with: 26 | path: ~/.pnpm-store 27 | key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} 28 | restore-keys: | 29 | ${{ runner.os }}-pnpm- 30 | 31 | - name: Install dependencies 32 | run: pnpm install 33 | 34 | - name: Cache Snyk cache folder 35 | uses: actions/cache@v4 36 | with: 37 | path: ~/.cache/snyk 38 | key: ${{ runner.os }}-snyk-${{ hashFiles('pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-snyk- 41 | 42 | - name: Run Snyk to check for vulnerabilities 43 | uses: snyk/actions/node@master 44 | env: 45 | SNYK_TOKEN: ${{ secrets.SNYK_SECRET }} 46 | with: 47 | args: --severity-threshold=high 48 | -------------------------------------------------------------------------------- /.github/workflows/sonar.yml: -------------------------------------------------------------------------------- 1 | name: Sonar scan (disabled) 2 | # Workflow disabled. Uncomment the 'on:' section below to re-enable. 3 | # on: 4 | # push: 5 | # branches: 6 | # - master 7 | # pull_request: 8 | # types: [opened, synchronize, reopened] 9 | jobs: 10 | sonarcloud: 11 | name: SonarCloud 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 17 | - name: SonarCloud Scan 18 | uses: SonarSource/sonarcloud-github-action@master 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 21 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | # /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | /dist 26 | /dist_site 27 | 28 | storybook-static 29 | 30 | debug 31 | .vscode 32 | .husky 33 | cache 34 | .dccache 35 | .idea 36 | .tmp 37 | 38 | cypress/videos 39 | 40 | .scannerwork 41 | # sonar-project.properties 42 | react-app-tester 43 | 44 | react-test-bed 45 | # Ignore Gradle project-specific cache directory 46 | .gradle 47 | 48 | # Ignore Gradle build output directory 49 | build 50 | 51 | ## Panda 52 | styled-system 53 | styled-system-studio 54 | 55 | venv 56 | extract_sonar_issues.py 57 | sonarqube_issues.csv 58 | requirements.txt -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | env 2 | icons 3 | src/demo 4 | src/examples -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.restyled.yaml: -------------------------------------------------------------------------------- 1 | exclude: 2 | - "**/*.patch" 3 | - "**/node_modules/**/*" 4 | - "**/vendor/**/*" 5 | - ".github/workflows/**/*" 6 | - ".gitignore" 7 | - "pnpm-lock.yaml" 8 | - "**/tsconfig.json" 9 | - coverage/**/* 10 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.22.1 3 | ignore: {} 4 | patch: {} 5 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "customSyntax": "postcss-styled-syntax", 3 | "rules": {}, 4 | "ignoreFiles": ["node_modules/**/*", "dist/**/*"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deepscan.ignoreConfirmWarning": true, 3 | "typescript.tsdk": "node_modules\\typescript\\lib", 4 | "cSpell.words": [ 5 | "actv", 6 | "chrono", 7 | "debounced", 8 | "debounced actv timeline item", 9 | "item", 10 | "timeline" 11 | ], 12 | "sonarlint.connectedMode.project": { 13 | "connectionId": "prabhuignoto", 14 | "projectKey": "prabhuignoto_react-chrono" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at https://www.contributor-covenant.org/version/1/0/0/code-of-conduct.html -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Fred K. Schott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | '@babel/plugin-transform-optional-chaining', 4 | '@babel/plugin-transform-typescript', 5 | 'babel-plugin-jsx-remove-data-test-id', 6 | ], 7 | // Adding presets for better compatibility and support 8 | presets: [ 9 | '@babel/preset-env', 10 | '@babel/preset-react', 11 | '@babel/preset-typescript', 12 | ], 13 | }; -------------------------------------------------------------------------------- /browserstack.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "username": "prabhu40", 4 | "access_key": "kdhsu2aDyBguV7cTCqZ4" 5 | }, 6 | "browsers": [ 7 | { 8 | "browser": "chrome", 9 | "os": "Windows 10", 10 | "versions": [ 11 | "latest", 12 | "latest-1" 13 | ] 14 | } 15 | ], 16 | "run_settings": { 17 | "cypress_config_file": "./cypress.json", 18 | "project_name": "project-name", 19 | "build_name": "build-name", 20 | "exclude": [], 21 | "parallels": "5", 22 | "npm_dependencies": { 23 | "@babel/plugin-proposal-optional-chaining": "^7.12.1", 24 | "@babel/core": "^7.12.3", 25 | "@babel/preset-react": "^7.10.4", 26 | "@babel/preset-typescript": "^7.10.4" 27 | }, 28 | "package_config_options": {} 29 | }, 30 | "connection_settings": { 31 | "local": true, 32 | "local_identifier": null 33 | }, 34 | "disable_usage_reporting": false 35 | } -------------------------------------------------------------------------------- /coverage/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/coverage/favicon.png -------------------------------------------------------------------------------- /coverage/lcov-report/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/coverage/lcov-report/favicon.png -------------------------------------------------------------------------------- /coverage/lcov-report/prettify.css: -------------------------------------------------------------------------------- 1 | .pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} 2 | -------------------------------------------------------------------------------- /coverage/lcov-report/sort-arrow-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/coverage/lcov-report/sort-arrow-sprite.png -------------------------------------------------------------------------------- /coverage/prettify.css: -------------------------------------------------------------------------------- 1 | .pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} 2 | -------------------------------------------------------------------------------- /coverage/sort-arrow-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/coverage/sort-arrow-sprite.png -------------------------------------------------------------------------------- /cypress/e2e/examples/aliasing.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Aliasing', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/aliasing'); 6 | }); 7 | 8 | it('.as() - alias a DOM element for later use', () => { 9 | // https://on.cypress.io/as 10 | 11 | // Alias a DOM element for use later 12 | // We don't have to traverse to the element 13 | // later in our code, we reference it with @ 14 | 15 | cy.get('.as-table') 16 | .find('tbody>tr') 17 | .first() 18 | .find('td') 19 | .first() 20 | .find('button') 21 | .as('firstBtn'); 22 | 23 | // when we reference the alias, we place an 24 | // @ in front of its name 25 | cy.get('@firstBtn').click(); 26 | 27 | cy.get('@firstBtn') 28 | .should('have.class', 'btn-success') 29 | .and('contain', 'Changed'); 30 | }); 31 | 32 | it('.as() - alias a route for later use', () => { 33 | // Alias the route to wait for its response 34 | cy.server(); 35 | cy.route('GET', 'comments/*').as('getComment'); 36 | 37 | // we have code that gets a comment when 38 | // the button is clicked in scripts.js 39 | cy.get('.network-btn').click(); 40 | 41 | // https://on.cypress.io/wait 42 | cy.wait('@getComment').its('status').should('eq', 200); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /cypress/e2e/examples/local_storage.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Local Storage', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/local-storage'); 6 | }); 7 | // Although local storage is automatically cleared 8 | // in between tests to maintain a clean state 9 | // sometimes we need to clear the local storage manually 10 | 11 | it('cy.clearLocalStorage() - clear all data in local storage', () => { 12 | // https://on.cypress.io/clearlocalstorage 13 | cy.get('.ls-btn') 14 | .click() 15 | .should(() => { 16 | expect(localStorage.getItem('prop1')).to.eq('red'); 17 | expect(localStorage.getItem('prop2')).to.eq('blue'); 18 | expect(localStorage.getItem('prop3')).to.eq('magenta'); 19 | }); 20 | 21 | // clearLocalStorage() yields the localStorage object 22 | cy.clearLocalStorage().should((ls) => { 23 | expect(ls.getItem('prop1')).to.be.null; 24 | expect(ls.getItem('prop2')).to.be.null; 25 | expect(ls.getItem('prop3')).to.be.null; 26 | }); 27 | 28 | // Clear key matching string in Local Storage 29 | cy.get('.ls-btn') 30 | .click() 31 | .should(() => { 32 | expect(localStorage.getItem('prop1')).to.eq('red'); 33 | expect(localStorage.getItem('prop2')).to.eq('blue'); 34 | expect(localStorage.getItem('prop3')).to.eq('magenta'); 35 | }); 36 | 37 | cy.clearLocalStorage('prop1').should((ls) => { 38 | expect(ls.getItem('prop1')).to.be.null; 39 | expect(ls.getItem('prop2')).to.eq('blue'); 40 | expect(ls.getItem('prop3')).to.eq('magenta'); 41 | }); 42 | 43 | // Clear keys matching regex in Local Storage 44 | cy.get('.ls-btn') 45 | .click() 46 | .should(() => { 47 | expect(localStorage.getItem('prop1')).to.eq('red'); 48 | expect(localStorage.getItem('prop2')).to.eq('blue'); 49 | expect(localStorage.getItem('prop3')).to.eq('magenta'); 50 | }); 51 | 52 | cy.clearLocalStorage(/prop1|2/).should((ls) => { 53 | expect(ls.getItem('prop1')).to.be.null; 54 | expect(ls.getItem('prop2')).to.be.null; 55 | expect(ls.getItem('prop3')).to.eq('magenta'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /cypress/e2e/examples/location.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Location', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/location'); 6 | }); 7 | 8 | it('cy.hash() - get the current URL hash', () => { 9 | // https://on.cypress.io/hash 10 | cy.hash().should('be.empty'); 11 | }); 12 | 13 | it('cy.location() - get window.location', () => { 14 | // https://on.cypress.io/location 15 | cy.location().should((location) => { 16 | expect(location.hash).to.be.empty; 17 | expect(location.href).to.eq( 18 | 'https://example.cypress.io/commands/location', 19 | ); 20 | expect(location.host).to.eq('example.cypress.io'); 21 | expect(location.hostname).to.eq('example.cypress.io'); 22 | expect(location.origin).to.eq('https://example.cypress.io'); 23 | expect(location.pathname).to.eq('/commands/location'); 24 | expect(location.port).to.eq(''); 25 | expect(location.protocol).to.eq('https:'); 26 | expect(location.search).to.be.empty; 27 | }); 28 | }); 29 | 30 | it('cy.url() - get the current URL', () => { 31 | // https://on.cypress.io/url 32 | cy.url().should('eq', 'https://example.cypress.io/commands/location'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /cypress/e2e/examples/navigation.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Navigation', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io'); 6 | cy.get('.navbar-nav').contains('Commands').click(); 7 | cy.get('.dropdown-menu').contains('Navigation').click(); 8 | }); 9 | 10 | it("cy.go() - go back or forward in the browser's history", () => { 11 | // https://on.cypress.io/go 12 | 13 | cy.location('pathname').should('include', 'navigation'); 14 | 15 | cy.go('back'); 16 | cy.location('pathname').should('not.include', 'navigation'); 17 | 18 | cy.go('forward'); 19 | cy.location('pathname').should('include', 'navigation'); 20 | 21 | // clicking back 22 | cy.go(-1); 23 | cy.location('pathname').should('not.include', 'navigation'); 24 | 25 | // clicking forward 26 | cy.go(1); 27 | cy.location('pathname').should('include', 'navigation'); 28 | }); 29 | 30 | it('cy.reload() - reload the page', () => { 31 | // https://on.cypress.io/reload 32 | cy.reload(); 33 | 34 | // reload the page without using the cache 35 | cy.reload(true); 36 | }); 37 | 38 | it('cy.visit() - visit a remote url', () => { 39 | // https://on.cypress.io/visit 40 | 41 | // Visit any sub-domain of your current domain 42 | 43 | // Pass options to the visit 44 | cy.visit('https://example.cypress.io/commands/navigation', { 45 | timeout: 50000, // increase total time for the visit to resolve 46 | onBeforeLoad(contentWindow) { 47 | // contentWindow is the remote page's window object 48 | expect(typeof contentWindow === 'object').to.be.true; 49 | }, 50 | onLoad(contentWindow) { 51 | // contentWindow is the remote page's window object 52 | expect(typeof contentWindow === 'object').to.be.true; 53 | }, 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /cypress/e2e/examples/viewport.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Viewport', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/viewport'); 6 | }); 7 | 8 | it('cy.viewport() - set the viewport size and dimension', () => { 9 | // https://on.cypress.io/viewport 10 | 11 | cy.get('#navbar').should('be.visible'); 12 | cy.viewport(320, 480); 13 | 14 | // the navbar should have collapse since our screen is smaller 15 | cy.get('#navbar').should('not.be.visible'); 16 | cy.get('.navbar-toggle').should('be.visible').click(); 17 | cy.get('.nav').find('a').should('be.visible'); 18 | 19 | // lets see what our app looks like on a super large screen 20 | cy.viewport(2999, 2999); 21 | 22 | // cy.viewport() accepts a set of preset sizes 23 | // to easily set the screen to a device's width and height 24 | 25 | // We added a cy.wait() between each viewport change so you can see 26 | // the change otherwise it is a little too fast to see :) 27 | 28 | cy.viewport('macbook-15'); 29 | cy.wait(200); 30 | cy.viewport('macbook-13'); 31 | cy.wait(200); 32 | cy.viewport('macbook-11'); 33 | cy.wait(200); 34 | cy.viewport('ipad-2'); 35 | cy.wait(200); 36 | cy.viewport('ipad-mini'); 37 | cy.wait(200); 38 | cy.viewport('iphone-6+'); 39 | cy.wait(200); 40 | cy.viewport('iphone-6'); 41 | cy.wait(200); 42 | cy.viewport('iphone-5'); 43 | cy.wait(200); 44 | cy.viewport('iphone-4'); 45 | cy.wait(200); 46 | cy.viewport('iphone-3'); 47 | cy.wait(200); 48 | 49 | // cy.viewport() accepts an orientation for all presets 50 | // the default orientation is 'portrait' 51 | cy.viewport('ipad-2', 'portrait'); 52 | cy.wait(200); 53 | cy.viewport('iphone-4', 'landscape'); 54 | cy.wait(200); 55 | 56 | // The viewport will be reset back to the default dimensions 57 | // in between tests (the default can be set in cypress.json) 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /cypress/e2e/examples/waiting.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Waiting', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/waiting'); 6 | }); 7 | // BE CAREFUL of adding unnecessary wait times. 8 | // https://on.cypress.io/best-practices#Unnecessary-Waiting 9 | 10 | // https://on.cypress.io/wait 11 | it('cy.wait() - wait for a specific amount of time', () => { 12 | cy.get('.wait-input1').type('Wait 1000ms after typing'); 13 | cy.wait(1000); 14 | cy.get('.wait-input2').type('Wait 1000ms after typing'); 15 | cy.wait(1000); 16 | cy.get('.wait-input3').type('Wait 1000ms after typing'); 17 | cy.wait(1000); 18 | }); 19 | 20 | it('cy.wait() - wait for a specific route', () => { 21 | cy.server(); 22 | 23 | // Listen to GET to comments/1 24 | cy.route('GET', 'comments/*').as('getComment'); 25 | 26 | // we have code that gets a comment when 27 | // the button is clicked in scripts.js 28 | cy.get('.network-btn').click(); 29 | 30 | // wait for GET comments/1 31 | cy.wait('@getComment').its('status').should('eq', 200); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /cypress/e2e/examples/window.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Window', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/window'); 6 | }); 7 | 8 | it('cy.window() - get the global window object', () => { 9 | // https://on.cypress.io/window 10 | cy.window().should('have.property', 'top'); 11 | }); 12 | 13 | it('cy.document() - get the document object', () => { 14 | // https://on.cypress.io/document 15 | cy.document().should('have.property', 'charset').and('eq', 'UTF-8'); 16 | }); 17 | 18 | it('cy.title() - get the title', () => { 19 | // https://on.cypress.io/title 20 | cy.title().should('include', 'Kitchen Sink'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /cypress/e2e/react-chrono/horizontal_spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Chrono.Horizontal.Basic', () => { 4 | beforeEach(() => { 5 | cy.visit('http://localhost:4444/horizontal'); 6 | }); 7 | 8 | it('check length', () => { 9 | cy.get('.timeline-horz-item-container').should('have.length', 10); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/e2e/react-chrono/vertical_alternating_nomedia.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Chrono.Vertical.Alternating', () => { 4 | beforeEach(() => { 5 | cy.visit('http://localhost:4444/vertical-alternating'); 6 | }); 7 | 8 | it('check length', () => { 9 | cy.get('.vertical-item-row').should('have.length', 13); 10 | }); 11 | 12 | it('check card elements', () => { 13 | cy.get('.vertical-item-row').first().children().should('have.length', 3); 14 | }); 15 | 16 | it('check timeline title for first item', () => { 17 | cy.get('.vertical-item-row>div').eq(1).last().should('contain', 'May 1940'); 18 | }); 19 | 20 | it('check timeline card contents', () => { 21 | cy.get('.vertical-item-row') 22 | .eq(3) 23 | .find('.timeline-card-content') 24 | .within(() => { 25 | cy.get('.rc-card-title').should('contain', 'Pearl Harbor'); 26 | cy.get('.rc-card-subtitle').should( 27 | 'contain', 28 | 'The destroyer USS Shaw explodes in dry dock after being hit by Japanese aircraft', 29 | ); 30 | }); 31 | }); 32 | 33 | it('check card title', () => { 34 | cy.get('.vertical-item-row') 35 | .eq(0) 36 | .find('.rc-card-title') 37 | .should('contain', 'Dunkirk'); 38 | }); 39 | 40 | it('check card description', () => { 41 | cy.get('.vertical-item-row') 42 | .eq(0) 43 | .find('.card-description>p') 44 | .should( 45 | 'contain', 46 | 'On 10 May 1940, Hitler began his long-awaited offensive in the west', 47 | ); 48 | }); 49 | 50 | it('check card sub title', () => { 51 | cy.get('.vertical-item-row') 52 | .eq(1) 53 | .find('.rc-card-subtitle') 54 | .should('contain', 'RAF Spitfire pilots scramble for their planes'); 55 | }); 56 | 57 | it('check card active', () => { 58 | cy.get('.vertical-item-row') 59 | .eq(1) 60 | .find('.timeline-card-content') 61 | .click() 62 | .should('have.class', 'active'); 63 | }); 64 | 65 | it('check scroll', () => { 66 | cy.get('.timeline-main-wrapper').scrollTo('bottom'); 67 | cy.wait(1000); 68 | cy.get('.vertical-item-row') 69 | .last() 70 | .find('.card-content-wrapper') 71 | .should('have.class', 'visible'); 72 | 73 | cy.get('.timeline-main-wrapper').scrollTo('top'); 74 | cy.wait(1000); 75 | cy.get('.vertical-item-row') 76 | .first() 77 | .find('.card-content-wrapper') 78 | .should('have.class', 'visible'); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | 11 | // Modern custom command example using cy.intercept() 12 | // Cypress.Commands.add('interceptApiCall', (method, url, alias) => { 13 | // cy.intercept(method, url).as(alias); 14 | // }); 15 | 16 | // Example of a custom command for testing React components 17 | // Cypress.Commands.add('getByDataTestId', (testId) => { 18 | // return cy.get(`[data-testid="${testId}"]`); 19 | // }); 20 | 21 | // Example of a custom command for accessibility testing 22 | // Cypress.Commands.add('checkA11y', (context, options) => { 23 | // cy.injectAxe(); 24 | // cy.checkA11y(context, options); 25 | // }); 26 | 27 | // TypeScript support for custom commands 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // interceptApiCall(method: string, url: string, alias: string): Chainable; 32 | // getByDataTestId(testId: string): Chainable; 33 | // checkA11y(context?: any, options?: any): Chainable; 34 | // } 35 | // } 36 | // } 37 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/react18' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | // Example use: 39 | // cy.mount() 40 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "cypress", 6 | "node" 7 | ], 8 | "target": "ES2022", 9 | "lib": ["ES2022", "DOM"], 10 | "module": "ESNext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "rootDir": ".." 19 | }, 20 | "include": [ 21 | "../node_modules/cypress", 22 | "./**/*.ts", 23 | "./**/*.tsx", 24 | "../cypress.config.ts", 25 | "../src/**/*.ts", 26 | "../src/**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "../node_modules", 30 | "../dist" 31 | ] 32 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'; 2 | import _import from 'eslint-plugin-import'; 3 | import react from 'eslint-plugin-react'; 4 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 5 | import jsxA11Y from 'eslint-plugin-jsx-a11y'; 6 | import typescriptSortKeys from 'eslint-plugin-typescript-sort-keys'; 7 | import sortKeysFix from 'eslint-plugin-sort-keys-fix'; 8 | import globals from 'globals'; 9 | import tsParser from '@typescript-eslint/parser'; 10 | import path from 'node:path'; 11 | import { fileURLToPath } from 'node:url'; 12 | import js from '@eslint/js'; 13 | import { FlatCompat } from '@eslint/eslintrc'; 14 | 15 | const __filename = fileURLToPath(import.meta.url); 16 | const __dirname = path.dirname(__filename); 17 | const compat = new FlatCompat({ 18 | allConfig: js.configs.all, 19 | baseDirectory: __dirname, 20 | recommendedConfig: js.configs.recommended, 21 | }); 22 | 23 | export default [ 24 | { 25 | ignores: ['src/demo/*', 'src/assets/*', 'src/examples/*'], 26 | }, 27 | ...fixupConfigRules( 28 | compat.extends( 29 | 'plugin:import/typescript', 30 | 'plugin:react/recommended', 31 | 'prettier', 32 | 'plugin:react/jsx-runtime', 33 | ), 34 | ), 35 | { 36 | languageOptions: { 37 | ecmaVersion: 12, 38 | 39 | globals: { 40 | ...globals.browser, 41 | }, 42 | parser: tsParser, 43 | parserOptions: { 44 | ecmaFeatures: { 45 | jsx: true, 46 | }, 47 | }, 48 | 49 | sourceType: 'module', 50 | }, 51 | 52 | plugins: { 53 | '@typescript-eslint': typescriptEslint, 54 | import: fixupPluginRules(_import), 55 | 'jsx-a11y': jsxA11Y, 56 | react: fixupPluginRules(react), 57 | 'sort-keys-fix': sortKeysFix, 58 | 'typescript-sort-keys': typescriptSortKeys, 59 | }, 60 | 61 | rules: { 62 | 'sort-keys-fix/sort-keys-fix': 'error', 63 | 'typescript-sort-keys/interface': 'error', 64 | 'typescript-sort-keys/string-enum': 'error', 65 | }, 66 | 67 | settings: { 68 | react: { 69 | version: '18.1.0', 70 | }, 71 | }, 72 | }, 73 | ]; 74 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Chrono 8 | 9 | 10 | 11 | You need to enable JavaScript to run this app. 12 | 13 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/camera.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/color-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/d-day.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/public/d-day.mp4 -------------------------------------------------------------------------------- /public/dunkirk.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/public/dunkirk.mp4 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/public/favicon.ico -------------------------------------------------------------------------------- /public/github copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/grid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/list.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/notification-bell.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/operation-barbarasso.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/public/operation-barbarasso.mp4 -------------------------------------------------------------------------------- /public/pearl-harbor.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/public/pearl-harbor.mp4 -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/rss.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/satellite-dish.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/zap.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/zapold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /readme-assets/horizontal_all.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/readme-assets/horizontal_all.jpg -------------------------------------------------------------------------------- /readme-assets/social-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/readme-assets/social-logo-small.png -------------------------------------------------------------------------------- /readme-assets/text_overlay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/readme-assets/text_overlay.jpg -------------------------------------------------------------------------------- /readme-assets/vertical_alternating.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/readme-assets/vertical_alternating.jpg -------------------------------------------------------------------------------- /readme-assets/vertical_basic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-chrono/235f9a131fd1112ff28b7d9e289e92763652c94a/readme-assets/vertical_basic.jpg -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=prabhuignoto_react-chrono 2 | sonar.organization=prabhuignoto 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=react-chrono 6 | #sonar.projectVersion=1.0 7 | 8 | sonar.sources=src 9 | sonar.exclusions=**/node_modules/**,**/*.test.js,**/*.spec.js,**/*.test.jsx,**/*.spec.jsx,**/*.test.ts,**/*.spec.ts,**/*.test.tsx,**/*.spec.tsx,coverage/**,build/**,dist/**,public/**,src/setupTests.js,src/setupTests.ts,src/setupTests.tsx,src/setupTests.jsx,src/setupTests.test.js,src/setupTests.test.ts,src/setupTests.test.tsx,src/setupTests.test.jsx,src/setupTests.spec.js,src/setupTests.spec.ts,src/setupTests.spec.tsx,src/setupTests.spec.jsx,src/serviceWorker.js,src/serviceWorker.ts,src/serviceWorker.tsx,src/serviceWorker.jsx,src/serviceWorker.test.js,src/serviceWorker.test.ts,src/serviceWorker.test.tsx,src/serviceWorker.test.jsx,src/serviceWorker.spec.js,src/serviceWorker.spec.ts,src/serviceWorker.spec.tsx,src/serviceWorker.spec.jsx,src/demo/**,src/components/**/*.style.ts,src/components/common/styles/index.ts,src/components/common/test/index.tsx 10 | 11 | # Coverage configuration 12 | sonar.javascript.lcov.reportPaths=coverage/lcov.info 13 | sonar.coverage.exclusions=**/*.test.js,**/*.spec.js,**/*.test.jsx,**/*.spec.jsx,**/*.test.ts,**/*.spec.ts,**/*.test.tsx,**/*.spec.tsx,coverage/**,build/**,dist/**,public/**,src/setupTests.js,src/setupTests.ts,src/setupTests.tsx,src/setupTests.jsx,src/setupTests.test.js,src/setupTests.test.ts,src/setupTests.test.tsx,src/setupTests.test.jsx,src/setupTests.spec.js,src/setupTests.spec.ts,src/setupTests.spec.tsx,src/setupTests.spec.jsx,src/serviceWorker.js,src/serviceWorker.ts,src/serviceWorker.tsx,src/serviceWorker.jsx,src/serviceWorker.test.js,src/serviceWorker.test.ts,src/serviceWorker.test.tsx,src/serviceWorker.test.jsx,src/serviceWorker.spec.js,src/serviceWorker.spec.ts,src/serviceWorker.spec.tsx,src/serviceWorker.spec.jsx,src/demo/**,src/components/**/*.style.ts,src/components/common/styles/index.ts,src/components/common/test/index.tsx 14 | 15 | # TypeScript specific settings 16 | sonar.typescript.file.suffixes=ts,tsx 17 | sonar.typescript.tsconfigPath=tsconfig.json 18 | 19 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 20 | #sonar.sources=. 21 | 22 | # Encoding of the source code. Default is default system encoding 23 | #sonar.sourceEncoding=UTF-8 24 | -------------------------------------------------------------------------------- /src/components/common/styles/index.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import { css } from 'styled-components'; 3 | 4 | export const ScrollBar = css<{ theme: Theme }>` 5 | scrollbar-color: ${(p) => p.theme?.primary} default; 6 | scrollbar-width: thin; 7 | 8 | &::-webkit-scrollbar { 9 | width: 0.3em; 10 | } 11 | 12 | &::-webkit-scrollbar-track { 13 | box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); 14 | } 15 | 16 | &::-webkit-scrollbar-thumb { 17 | background-color: ${(p) => p.theme?.primary}; 18 | outline: 1px solid ${(p) => p.theme?.primary}; 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /src/components/contexts/DynamicContext.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Dynamic Context - Contains frequently changing values like state and interactions 3 | * This context is optimized for dynamic values that change often 4 | */ 5 | import { createContext } from 'react'; 6 | import { TextDensity } from '@models/TimelineModel'; 7 | import { Theme } from '@models/Theme'; 8 | 9 | export interface DynamicContextProps { 10 | // Dynamic state values 11 | isDarkMode: boolean; 12 | isMobile: boolean; 13 | horizontalAll: boolean; 14 | textContentDensity: TextDensity; 15 | 16 | // Theme (can change with dark mode) 17 | memoizedTheme: Theme; 18 | 19 | // Interaction callbacks 20 | toggleDarkMode?: () => void; 21 | updateHorizontalAllCards?: (state: boolean) => void; 22 | updateTextContentDensity?: (value: TextDensity) => void; 23 | } 24 | 25 | export const DynamicContext = createContext({ 26 | isDarkMode: false, 27 | isMobile: false, 28 | horizontalAll: false, 29 | textContentDensity: 'HIGH', 30 | memoizedTheme: { 31 | primary: '#007FFF', 32 | secondary: '#ffdf00', 33 | cardBgColor: '#ffffff', 34 | cardDetailsBackGround: '#ffffff', 35 | cardDetailsColor: '#000', 36 | cardMediaBgColor: '#f5f5f5', 37 | cardSubtitleColor: '#000', 38 | cardTitleColor: '#007FFF', 39 | detailsColor: '#000', 40 | iconBackgroundColor: '#007FFF', 41 | nestedCardBgColor: '#f5f5f5', 42 | nestedCardDetailsBackGround: '#f5f5f5', 43 | nestedCardDetailsColor: '#000', 44 | nestedCardSubtitleColor: '#000', 45 | nestedCardTitleColor: '#000', 46 | titleColor: '#007FFF', 47 | titleColorActive: '#007FFF', 48 | toolbarBgColor: '#f5f5f5', 49 | toolbarBtnBgColor: '#ffffff', 50 | toolbarTextColor: '#000', 51 | timelineBgColor: '#f5f5f5', 52 | }, 53 | // Default no-op implementations to prevent fallback to legacy context 54 | toggleDarkMode: () => {}, 55 | updateHorizontalAllCards: () => {}, 56 | updateTextContentDensity: () => {}, 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/contexts/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Context index file - exports all context-related functionality 3 | */ 4 | export { StableContext, type StableContextProps } from './StableContext'; 5 | export { DynamicContext, type DynamicContextProps } from './DynamicContext'; 6 | export { 7 | useStableContext, 8 | useDynamicContext, 9 | useGlobalContext, 10 | useTimelineContext, 11 | type CombinedContextProps, 12 | } from './hooks'; 13 | 14 | // Import and re-export to work around module resolution issue 15 | import { 16 | OptimizedContextProvider, 17 | type ContextProps, 18 | } from './OptimizedContextProvider'; 19 | export { OptimizedContextProvider }; 20 | export type { ContextProps }; 21 | -------------------------------------------------------------------------------- /src/components/contexts/legacy-types.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Legacy context types and default functions 3 | * This file contains only type definitions and default functions to avoid circular dependencies 4 | */ 5 | import { createContext } from 'react'; 6 | import { 7 | TimelineProps as PropsModel, 8 | TextDensity, 9 | } from '@models/TimelineModel'; 10 | 11 | export type LegacyContextProps = PropsModel & { 12 | isMobile?: boolean; 13 | toggleDarkMode?: () => void; 14 | updateHorizontalAllCards?: (state: boolean) => void; 15 | updateTextContentDensity?: (value: TextDensity) => void; 16 | }; 17 | 18 | export interface ButtonTexts { 19 | first?: string; 20 | last?: string; 21 | play?: string; 22 | stop?: string; 23 | previous?: string; 24 | next?: string; 25 | dark?: string; 26 | light?: string; 27 | timelinePoint?: string; 28 | searchPlaceholder?: string; 29 | searchAriaLabel?: string; 30 | clearSearch?: string; 31 | nextMatch?: string; 32 | previousMatch?: string; 33 | } 34 | 35 | // Legacy context for backward compatibility - exported without circular dependency 36 | export const LegacyGlobalContext = createContext({}); 37 | -------------------------------------------------------------------------------- /src/components/effects/__tests__/useSlideshow.test.ts: -------------------------------------------------------------------------------- 1 | // Test setup 2 | import { act, renderHook } from '@testing-library/react'; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 4 | import { useSlideshow } from '../useSlideshow'; 5 | 6 | const slideItemDuration = 5000; 7 | const onElapsed = vi.fn(); 8 | 9 | let container = null; 10 | 11 | beforeEach(() => { 12 | container = document.createElement('div'); 13 | document.body.appendChild(container); 14 | }); 15 | 16 | afterEach(() => { 17 | document.body.removeChild(container); 18 | vi.clearAllMocks(); 19 | }); 20 | 21 | describe('useSlideshow', () => { 22 | it('should initialize properly', () => { 23 | const { result } = renderHook(() => 24 | useSlideshow( 25 | { current: container }, 26 | true, 27 | true, 28 | slideItemDuration, 29 | '1', 30 | onElapsed, 31 | ), 32 | ); 33 | 34 | expect(result.current.paused).toBe(false); 35 | expect(result.current.remainInterval).toBe(0); 36 | expect(result.current.startWidth).toBe(0); 37 | }); 38 | 39 | it('should set up timer correctly', async () => { 40 | const { result } = renderHook(() => 41 | useSlideshow( 42 | { current: container }, 43 | true, 44 | true, 45 | slideItemDuration, 46 | '1', 47 | onElapsed, 48 | ), 49 | ); 50 | 51 | expect(result.current.tryPause).toBeDefined(); 52 | expect(result.current.tryResume).toBeDefined(); 53 | expect(result.current.setStartWidth).toBeDefined(); 54 | expect(result.current.setupTimer).toBeDefined(); 55 | expect(result.current.startWidth).toBe(0); 56 | expect(result.current.paused).toBe(false); 57 | }); 58 | 59 | it('should pause slideshow', () => { 60 | const { result } = renderHook(() => 61 | useSlideshow( 62 | { current: container }, 63 | true, 64 | true, 65 | slideItemDuration, 66 | '1', 67 | onElapsed, 68 | ), 69 | ); 70 | 71 | expect(result.current.paused).toBe(false); 72 | 73 | act(() => { 74 | result.current.tryPause(); 75 | }); 76 | 77 | expect(result.current.paused).toBe(true); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/effects/useCloseClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | import useOutsideClick from '../../hooks/useOutsideClick'; 3 | import useEscapeKey from '../../hooks/useEscapeKey'; 4 | 5 | /** 6 | * Custom hook that handles click outside and escape key events 7 | * @param el - Reference to the DOM element to watch for outside clicks 8 | * @param callback - Function to call when a click outside or escape key is detected 9 | */ 10 | export default function useCloseClickOutside( 11 | el: RefObject, 12 | callback: () => void, 13 | ) { 14 | useOutsideClick(el, callback); 15 | useEscapeKey(callback); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/elements/list/__tests__/list.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { fireEvent } from '@testing-library/react'; 4 | import { getDefaultThemeOrDark } from '@utils/index'; 5 | import { customRender, providerProps } from 'src/components/common/test'; 6 | import { List } from '../list'; 7 | 8 | const items = [ 9 | { description: 'Description 1', id: '1', title: 'Item 1' }, 10 | { description: 'Description 2', id: '2', title: 'Item 2' }, 11 | { description: 'Description 3', id: '3', title: 'Item 3' }, 12 | ]; 13 | 14 | describe('List', () => { 15 | const theme = getDefaultThemeOrDark(false); 16 | 17 | it('should render the correct number of list items', () => { 18 | const { getAllByRole } = customRender( 19 | , 20 | { 21 | providerProps: { 22 | ...providerProps, 23 | }, 24 | }, 25 | ); 26 | const listItems = getAllByRole('listitem'); 27 | expect(listItems).toHaveLength(items.length); 28 | }); 29 | 30 | it('should call onClick when a list item is clicked', () => { 31 | const onClick = vi.fn(); 32 | const { getByText } = customRender( 33 | , 34 | { 35 | providerProps: { 36 | ...providerProps, 37 | }, 38 | }, 39 | ); 40 | const listItem = getByText('Item 1'); 41 | fireEvent.click(listItem); 42 | expect(onClick).toHaveBeenCalledTimes(1); 43 | }); 44 | 45 | it('should call onSelect when a list item is clicked and multiSelectable is true', () => { 46 | const onSelectItem = vi.fn(); 47 | const itemsWithOnSelect = items.map((item, index) => ({ 48 | ...item, 49 | onSelect: index === 0 ? onSelectItem : undefined, 50 | })); 51 | const { getByText } = customRender( 52 | , 53 | { 54 | providerProps: { 55 | ...providerProps, 56 | }, 57 | }, 58 | ); 59 | 60 | const listItem = getByText('Item 1'); 61 | fireEvent.click(listItem); 62 | expect(onSelectItem).toHaveBeenCalledTimes(1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/elements/list/list.model.ts: -------------------------------------------------------------------------------- 1 | import { TimelineModel } from '@models/TimelineModel'; 2 | 3 | export type ListModel = { 4 | activeItemIndex?: number; 5 | items: ListItemModel[]; 6 | multiSelectable?: boolean; 7 | onClick?: (id?: string) => void; 8 | } & Pick; 9 | 10 | export type ListItemModel = { 11 | active?: boolean; 12 | description: string; 13 | id: string; 14 | onClick?: (id: string) => void; 15 | onSelect?: () => void; 16 | selectable?: boolean; 17 | selected?: boolean; 18 | title: string; 19 | } & Pick; 20 | -------------------------------------------------------------------------------- /src/components/elements/popover/popover.model.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import { ReactNode } from 'react'; 3 | 4 | export type PopOverModel = { 5 | $isMobile?: boolean; 6 | children: ReactNode | ReactNode[]; 7 | icon?: ReactNode; 8 | isDarkMode?: boolean; 9 | placeholder?: string; 10 | position: 'top' | 'bottom'; 11 | theme?: Theme; 12 | width?: number; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/icons/arrow-down.tsx: -------------------------------------------------------------------------------- 1 | const ArrowDownIcon = () => ( 2 | 7 | 8 | 9 | ) 10 | export default ArrowDownIcon 11 | -------------------------------------------------------------------------------- /src/components/icons/check.tsx: -------------------------------------------------------------------------------- 1 | const CheckIcon = () => ( 2 | 11 | 12 | 13 | ) 14 | export default CheckIcon 15 | -------------------------------------------------------------------------------- /src/components/icons/chev-down.tsx: -------------------------------------------------------------------------------- 1 | const ChevDownIcon = () => ( 2 | 11 | 12 | 13 | ) 14 | export default ChevDownIcon 15 | -------------------------------------------------------------------------------- /src/components/icons/chev-left.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ChevronLeft: React.FunctionComponent = () => ( 4 | 16 | 17 | 18 | ); 19 | 20 | export default ChevronLeft; 21 | -------------------------------------------------------------------------------- /src/components/icons/chev-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ChevronRightIcon: React.FunctionComponent = () => ( 4 | 16 | 17 | 18 | ); 19 | 20 | export default ChevronRightIcon; 21 | -------------------------------------------------------------------------------- /src/components/icons/chev-up.tsx: -------------------------------------------------------------------------------- 1 | const ChevUpIcon = () => ( 2 | 10 | 11 | 12 | ) 13 | export default ChevUpIcon 14 | -------------------------------------------------------------------------------- /src/components/icons/chevs-left.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ChevronLeft: React.FunctionComponent = () => ( 4 | 16 | 17 | 18 | 19 | ); 20 | 21 | export default ChevronLeft; 22 | -------------------------------------------------------------------------------- /src/components/icons/chevs-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ChevronRightIcon: React.FunctionComponent = () => ( 4 | 16 | 17 | 18 | 19 | ); 20 | 21 | export default ChevronRightIcon; 22 | -------------------------------------------------------------------------------- /src/components/icons/close.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgComponent() { 4 | return ( 5 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default SvgComponent 23 | -------------------------------------------------------------------------------- /src/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as ArrowDownIcon } from './arrow-down'; 2 | export { default as CheckIcon } from './check'; 3 | export { default as ChevronDown } from './chev-down'; 4 | export { default as ChevronLeft } from './chev-left'; 5 | export { default as ChevronRight } from './chev-right'; 6 | export { default as ChevronUp } from './chev-up'; 7 | export { default as CloseIcon } from './close'; 8 | export { default as LayoutIcon } from './layout'; 9 | export { default as MaximizeIcon } from './maximize'; 10 | export { default as MinimizeIcon } from './minimize'; 11 | export { default as MinusIcon } from './minus'; 12 | export { default as MoonIcon } from './moon'; 13 | export { default as PlusIcon } from './plus'; 14 | export { default as StopIcon } from "./stop"; 15 | export { default as SunIcon } from './sun'; 16 | export { default as TextIcon } from './text'; 17 | export {default as ParaIcon} from './para'; 18 | -------------------------------------------------------------------------------- /src/components/icons/layout.tsx: -------------------------------------------------------------------------------- 1 | const LayoutIcon = (props) => ( 2 | 9 | 10 | 11 | ) 12 | export default LayoutIcon 13 | -------------------------------------------------------------------------------- /src/components/icons/maximize.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 13 | 14 | 15 | ) 16 | 17 | export default SvgComponent 18 | -------------------------------------------------------------------------------- /src/components/icons/menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgComponent() { 4 | return ( 5 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default SvgComponent 23 | -------------------------------------------------------------------------------- /src/components/icons/minimize.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 12 | 13 | 14 | ) 15 | 16 | export default SvgComponent 17 | -------------------------------------------------------------------------------- /src/components/icons/minus.tsx: -------------------------------------------------------------------------------- 1 | 2 | const SvgComponent = () => ( 3 | 12 | 13 | 14 | ) 15 | 16 | export default SvgComponent 17 | -------------------------------------------------------------------------------- /src/components/icons/moon.tsx: -------------------------------------------------------------------------------- 1 | 2 | const SvgComponent = () => ( 3 | 12 | 13 | 14 | ) 15 | 16 | export default SvgComponent 17 | -------------------------------------------------------------------------------- /src/components/icons/para.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 7 | 8 | 9 | ) 10 | export default SvgComponent 11 | -------------------------------------------------------------------------------- /src/components/icons/plus.tsx: -------------------------------------------------------------------------------- 1 | 2 | const SvgComponent = () => ( 3 | 12 | 13 | 14 | ) 15 | 16 | export default SvgComponent 17 | -------------------------------------------------------------------------------- /src/components/icons/replay-icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ReplayIcon: React.FunctionComponent = () => ( 4 | 15 | 16 | 17 | ); 18 | 19 | export default ReplayIcon; 20 | -------------------------------------------------------------------------------- /src/components/icons/stop.tsx: -------------------------------------------------------------------------------- 1 | 2 | const SvgComponent = () => ( 3 | 12 | 13 | 14 | 15 | ) 16 | 17 | export default SvgComponent 18 | -------------------------------------------------------------------------------- /src/components/icons/sun.tsx: -------------------------------------------------------------------------------- 1 | 2 | const SvgComponent = () => ( 3 | 13 | 14 | 15 | 16 | ) 17 | 18 | export default SvgComponent 19 | -------------------------------------------------------------------------------- /src/components/icons/text.tsx: -------------------------------------------------------------------------------- 1 | const TextIcon = () => ( 2 | 7 | 8 | 9 | ) 10 | export default TextIcon 11 | -------------------------------------------------------------------------------- /src/components/icons/triangle-right.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgComponent() { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default SvgComponent 12 | -------------------------------------------------------------------------------- /src/components/timeline-elements/memoized/details-text-memo.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback } from 'react'; 2 | import { useBackground } from '../../../hooks/useBackground'; 3 | import { DetailsTextWrapper } from '../timeline-card-media/timeline-card-media.styles'; 4 | import { DetailsTextMemoModel } from './memoized-model'; 5 | 6 | const useMeasureHeight = (onRender?: (height: number) => void) => { 7 | return useCallback( 8 | (node: HTMLDivElement | null) => { 9 | if (node && onRender) { 10 | requestAnimationFrame(() => onRender(node.clientHeight)); 11 | } 12 | }, 13 | [onRender], 14 | ); 15 | }; 16 | 17 | const arePropsEqual = ( 18 | prev: DetailsTextMemoModel, 19 | next: DetailsTextMemoModel, 20 | ): boolean => { 21 | return ( 22 | prev.height === next.height && 23 | prev.show === next.show && 24 | prev.expand === next.expand && 25 | prev.theme?.cardDetailsBackGround === next.theme?.cardDetailsBackGround 26 | ); 27 | }; 28 | 29 | const DetailsTextMemo = memo( 30 | ({ theme, show, expand, textOverlay, text: Text, onRender }) => { 31 | const background = useBackground(theme?.cardDetailsBackGround); 32 | const measureRef = useMeasureHeight(onRender); 33 | 34 | if (!textOverlay) return null; 35 | 36 | return ( 37 | 44 | 45 | 46 | ); 47 | }, 48 | arePropsEqual, 49 | ); 50 | 51 | DetailsTextMemo.displayName = 'DetailsText'; 52 | 53 | export { DetailsTextMemo }; 54 | -------------------------------------------------------------------------------- /src/components/timeline-elements/memoized/expand-button-memo.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo } from 'react'; 2 | import { MaximizeIcon, MinimizeIcon } from '../../icons'; 3 | import { ExpandButton } from '../timeline-card-media/timeline-card-media-buttons'; 4 | import { ExpandButtonModel } from './memoized-model'; 5 | 6 | /** 7 | * Renders a button to expand or collapse timeline card content. 8 | * @param {ExpandButtonModel} props - The expand button properties 9 | * @returns {JSX.Element | null} The expand/collapse button 10 | */ 11 | const ExpandButtonMemo = memo( 12 | ({ theme, expanded, onExpand, textOverlay }: ExpandButtonModel) => { 13 | const label = useMemo(() => { 14 | return expanded ? 'Minimize' : 'Maximize'; 15 | }, [expanded]); 16 | 17 | return textOverlay ? ( 18 | ev.key === 'Enter' && onExpand?.(ev)} 21 | theme={theme} 22 | aria-expanded={expanded} 23 | tabIndex={0} 24 | aria-label={label} 25 | title={label} 26 | > 27 | {expanded ? : } 28 | 29 | ) : null; 30 | }, 31 | (prev, next) => prev.expanded === next.expanded, 32 | ); 33 | 34 | ExpandButtonMemo.displayName = 'Expand Button'; 35 | 36 | export { ExpandButtonMemo }; 37 | -------------------------------------------------------------------------------- /src/components/timeline-elements/memoized/memoized-model.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '../../../models/Theme'; 2 | import React, { ReactNode } from 'react'; 3 | 4 | /** 5 | * Common properties shared by multiple interfaces. 6 | */ 7 | type common = { 8 | classString?: string; // CSS class string 9 | color?: string; // Color value 10 | dir?: string; // Text direction 11 | fontSize?: string; // Font size 12 | $padding?: boolean; // Whether to apply padding 13 | theme?: Theme; // Theme object 14 | }; 15 | 16 | /** 17 | * Interface for the Title component. 18 | */ 19 | export interface Title extends common { 20 | active?: boolean; // Whether the title is active 21 | title?: string | ReactNode; // Title text or ReactNode 22 | url?: string; // URL for the title 23 | } 24 | 25 | /** 26 | * Interface for the Content component. 27 | */ 28 | export interface Content extends common { 29 | content?: string | ReactNode; // Content text or ReactNode 30 | padding?: boolean; // For backward compatibility 31 | } 32 | 33 | /** 34 | * Type for the ExpandButtonModel. 35 | */ 36 | export type ExpandButtonModel = { 37 | expanded?: boolean; // Whether the button is expanded 38 | onExpand?: (ev: React.PointerEvent | React.KeyboardEvent) => void; // Event handler for expand action 39 | textOverlay?: boolean; // Whether to overlay text on the button 40 | } & Pick; 41 | 42 | /** 43 | * Type for the ShowHideTextButtonModel. 44 | */ 45 | export type ShowHideTextButtonModel = { 46 | onToggle: (ev: React.PointerEvent | React.KeyboardEvent) => void; // Event handler for toggle action 47 | show?: boolean; // Whether to show the button 48 | textOverlay?: boolean; // Whether to overlay text on the button 49 | } & Pick; 50 | 51 | /** 52 | * Type for the DetailsTextMemoModel. 53 | */ 54 | export type DetailsTextMemoModel = { 55 | theme?: Theme; // Theme object 56 | show: boolean; // Whether to show the details text 57 | expand: boolean; // Whether to expand the details text 58 | textOverlay: boolean; // Whether to overlay text on the details text 59 | text: React.FC; // Text component for the details text 60 | height?: number; // Height of the details text 61 | onRender?: (height: number) => void; // Callback function for rendering the details text 62 | }; 63 | 64 | /** 65 | * Type for the TextContentMemoModel. 66 | */ 67 | export type TextContentMemoModel = Title & 68 | Content & 69 | ExpandButtonModel & 70 | ShowHideTextButtonModel & 71 | DetailsTextMemoModel; 72 | 73 | /** 74 | * Type for the CardMediaHeaderMemoModel. 75 | */ 76 | export type CardMediaHeaderMemoModel = Title & Content; 77 | -------------------------------------------------------------------------------- /src/components/timeline-elements/memoized/show-hide-button.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo } from 'react'; 2 | import { MinusIcon, PlusIcon } from '../../icons'; 3 | import { ShowHideTextButton } from '../timeline-card-media/timeline-card-media-buttons'; 4 | import { ShowHideTextButtonModel } from './memoized-model'; 5 | 6 | /** 7 | * Renders a button to toggle showing or hiding text content. 8 | * @param {ShowHideTextButtonModel} props - Button properties 9 | * @returns {JSX.Element | null} The toggle button 10 | */ 11 | const ShowOrHideTextButtonMemo = memo( 12 | ({ textOverlay, onToggle, theme, show }: ShowHideTextButtonModel) => { 13 | const label = useMemo(() => { 14 | return show ? 'Hide Text' : 'Show Text'; 15 | }, [show]); 16 | 17 | return textOverlay ? ( 18 | ev.key === 'Enter' && onToggle?.(ev)} 23 | aria-label={label} 24 | title={label} 25 | > 26 | {show ? : } 27 | 28 | ) : null; 29 | }, 30 | ); 31 | 32 | ShowOrHideTextButtonMemo.displayName = 'Show Hide Text Button'; 33 | 34 | export { ShowOrHideTextButtonMemo }; 35 | -------------------------------------------------------------------------------- /src/components/timeline-elements/memoized/subtitle-memo.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import React, { useContext } from 'react'; 3 | import { CardSubTitleSemantic } from '../timeline-card-content/timeline-card-content.styles'; 4 | import { Content } from './memoized-model'; 5 | import { GlobalContext } from '../../GlobalContext'; 6 | 7 | /** 8 | * Renders the subtitle content for the timeline card using configurable semantic tags. 9 | * @param {Content} props - Subtitle properties 10 | * @returns {JSX.Element | null} The rendered subtitle 11 | */ 12 | const SubTitleMemo = React.memo( 13 | ({ content, color, dir, theme, fontSize, classString, padding }: Content) => { 14 | const { semanticTags } = useContext(GlobalContext); 15 | 16 | return content ? ( 17 | 26 | {content} 27 | 28 | ) : null; 29 | }, 30 | (prev, next) => 31 | prev.theme?.cardSubtitleColor === next.theme?.cardSubtitleColor, 32 | ); 33 | 34 | SubTitleMemo.displayName = 'Timeline Content'; 35 | 36 | export { SubTitleMemo }; 37 | -------------------------------------------------------------------------------- /src/components/timeline-elements/memoized/title-memo.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import { 3 | CardTitleAnchor, 4 | CardTitleSemantic, 5 | } from '../timeline-card-content/timeline-card-content.styles'; 6 | import { Title } from './memoized-model'; 7 | import React, { useContext } from 'react'; 8 | import { GlobalContext } from '../../GlobalContext'; 9 | 10 | /** 11 | * Renders the timeline's title with optional link using configurable semantic tags. 12 | * @param {Title} props - Title properties 13 | * @returns {JSX.Element | null} The rendered title 14 | */ 15 | const TitleMemoComponent = ({ 16 | title, 17 | url, 18 | theme, 19 | color, 20 | dir, 21 | active, 22 | fontSize = '1rem', 23 | classString = '', 24 | }: Title) => { 25 | const { semanticTags } = useContext(GlobalContext); 26 | 27 | return title ? ( 28 | 37 | {url ? ( 38 | 39 | {title} 40 | 41 | ) : ( 42 | title 43 | )} 44 | 45 | ) : null; 46 | }; 47 | 48 | export const TitleMemo = React.memo(TitleMemoComponent); 49 | 50 | TitleMemo.displayName = 'Timeline Title'; 51 | -------------------------------------------------------------------------------- /src/components/timeline-elements/nested-timeline-renderer/nested-timeline-renderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TimelineModel } from '@models/TimelineModel'; 3 | 4 | interface NestedTimelineRendererProps { 5 | items: any[]; 6 | nestedCardHeight?: number; 7 | mode?: string; 8 | isChild?: boolean; 9 | } 10 | 11 | // This component will be used to dynamically load the Timeline component 12 | // to avoid circular dependency issues 13 | const NestedTimelineRenderer: React.FC = ({ 14 | items, 15 | nestedCardHeight, 16 | mode = 'HORIZONTAL', 17 | isChild, 18 | }) => { 19 | const [TimelineComponent, setTimelineComponent] = 20 | React.useState | null>(null); 21 | 22 | // Dynamically import the Timeline component only when needed 23 | React.useEffect(() => { 24 | import('../../timeline/timeline').then((module) => { 25 | setTimelineComponent(() => module.default); 26 | }); 27 | }, []); 28 | 29 | if (!TimelineComponent) { 30 | return Loading nested timeline...; 31 | } 32 | 33 | return ( 34 | 40 | ); 41 | }; 42 | 43 | export default NestedTimelineRenderer; 44 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/__tests__/__snapshots__/content-footer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ContentFooter > should match snapshot 1`] = ``; 4 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/__tests__/__snapshots__/content-header.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Content Header > should match the snapshot 1`] = ` 4 | 5 | 8 | 12 | title 13 | 14 | 17 | content 18 | 19 | 20 | 21 | `; 22 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/__tests__/content-header.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest'; 2 | import { customRender } from '../../../common/test'; 3 | import { providerProps } from '../../../common/test/index'; 4 | import { ContentHeader } from '../content-header'; 5 | 6 | describe('Content Header', () => { 7 | // should render the title and subtitle of the card 8 | it('should render the title and subtitle of the card', () => { 9 | const { getByText } = customRender( 10 | , 11 | { 12 | providerProps: { 13 | ...providerProps, 14 | media: null, 15 | }, 16 | }, 17 | ); 18 | 19 | expect(getByText('title')).toBeInTheDocument(); 20 | expect(getByText('content')).toBeInTheDocument(); 21 | }); 22 | 23 | // should match the snapshot 24 | it('should match the snapshot', () => { 25 | const { container } = customRender( 26 | , 27 | { 28 | providerProps, 29 | }, 30 | ); 31 | 32 | expect(container).toMatchSnapshot(); 33 | }); 34 | 35 | // should render the link if url is provided 36 | it('should render the link if url is provided', () => { 37 | const { getByText, getByRole } = customRender( 38 | , 43 | { 44 | providerProps, 45 | }, 46 | ); 47 | 48 | expect(getByText('title')).toBeInTheDocument(); 49 | expect(getByRole('link')).toHaveAttribute('href', 'http://www.google.com'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/__tests__/text_or_content.test.tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import { getDefaultClassNames } from '@utils/index'; 3 | import { customRender, providerProps } from 'src/components/common/test'; 4 | import { getTextOrContent } from '../text-or-content'; 5 | 6 | describe('getTextOrContent', () => { 7 | const classNames = getDefaultClassNames(); 8 | 9 | it('renders TimelineContentDetails when detailedText is string', () => { 10 | const TextContent = getTextOrContent({ 11 | detailedText: 'Hello World', 12 | }); 13 | 14 | const { getByText } = customRender(, { 15 | providerProps: { 16 | ...providerProps, 17 | }, 18 | }); 19 | expect(getByText('Hello World')).toBeInTheDocument(); 20 | }); 21 | 22 | it('renders TimelineContentDetails with xss when parseDetailsAsHTML is true', () => { 23 | const TextContent = getTextOrContent({ 24 | detailedText: 'Hello', 25 | }); 26 | 27 | const { container } = customRender(, { 28 | providerProps: { 29 | ...providerProps, 30 | parseDetailsAsHTML: true, 31 | }, 32 | }); 33 | 34 | expect(container.firstChild).toHaveTextContent('Hello'); // xss sanitized 35 | }); 36 | 37 | it('renders TimelineSubContent when detailedText is array', () => { 38 | const TextContent = getTextOrContent({ 39 | detailedText: ['Line 1', 'Line 2'], 40 | }); 41 | 42 | const { getAllByText } = customRender(, { 43 | providerProps: { 44 | ...providerProps, 45 | }, 46 | }); 47 | expect(getAllByText('Line 1')[0]).toBeInTheDocument(); 48 | expect(getAllByText('Line 2')[0]).toBeInTheDocument(); 49 | }); 50 | 51 | it('passes showMore prop to TimelineContentDetails', () => { 52 | const TextContent = getTextOrContent({ 53 | detailedText: 'Text', 54 | showMore: true, 55 | }); 56 | 57 | const { getByText } = customRender(, { 58 | providerProps: { 59 | ...providerProps, 60 | }, 61 | }); 62 | expect(getByText('Text')).toHaveClass('active'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/card-animations.styles.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from 'styled-components'; 2 | 3 | export const reveal = keyframes` 4 | 0% { 5 | opacity: 0; 6 | transform: scale(0.95); 7 | } 8 | 100% { 9 | opacity: 1; 10 | transform: scale(1); 11 | } 12 | `; 13 | 14 | export const slideInFromTop = keyframes` 15 | 0% { 16 | opacity: 0; 17 | transform: translateY(-50%); 18 | } 19 | 100% { 20 | opacity: 1; 21 | transform: translateY(0); 22 | } 23 | `; 24 | 25 | export const slideInFromLeft = keyframes` 26 | 0% { 27 | opacity: 0; 28 | transform: translateX(-50%); 29 | } 30 | 100% { 31 | opacity: 1; 32 | transform: translateX(0); 33 | } 34 | `; 35 | 36 | export const slideFromRight = keyframes` 37 | 0% { 38 | opacity: 0; 39 | transform: translateX(50%); 40 | } 41 | 100% { 42 | opacity: 1; 43 | transform: translateX(0); 44 | } 45 | `; 46 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/details-text.model.ts: -------------------------------------------------------------------------------- 1 | import { TimelineContentModel } from '@models/TimelineContentModel'; 2 | import { ReactNode } from 'react'; 3 | 4 | export type DetailsTextProps = { 5 | cardActualHeight?: number; 6 | contentDetailsClass?: string; 7 | customContent?: ReactNode; 8 | detailedText: TimelineContentModel['detailedText']; 9 | detailsHeight?: number; 10 | gradientColor?: string; 11 | showMore?: boolean; 12 | timelineContent: TimelineContentModel['timelineContent']; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/details-text.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useContext } from 'react'; 2 | import { GlobalContext } from '../../GlobalContext'; 3 | import { DetailsTextProps } from './details-text.model'; 4 | import { getTextOrContent } from './text-or-content'; 5 | import { TimelineContentDetailsWrapper } from './timeline-card-content.styles'; 6 | 7 | const DetailsText = forwardRef( 8 | (prop, ref) => { 9 | const { 10 | showMore, 11 | cardActualHeight, 12 | detailsHeight, 13 | gradientColor, 14 | customContent, 15 | timelineContent, 16 | detailedText, 17 | contentDetailsClass, 18 | } = prop; 19 | 20 | const { 21 | useReadMore, 22 | borderLessCards, 23 | contentDetailsHeight, 24 | textOverlay, 25 | theme, 26 | } = useContext(GlobalContext); 27 | 28 | const TextContent = getTextOrContent({ 29 | detailedText, 30 | showMore, 31 | theme, 32 | timelineContent, 33 | }); 34 | 35 | return ( 36 | <> 37 | {/* detailed text */} 38 | 53 | {customContent ?? ( 54 | 57 | )} 58 | 59 | > 60 | ); 61 | }, 62 | ); 63 | 64 | DetailsText.displayName = 'Details Text'; 65 | 66 | export { DetailsText }; 67 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-content/header-footer.model.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import { TimelineContentModel } from '@models/TimelineContentModel'; 3 | 4 | export type ContentHeaderProps = Pick< 5 | TimelineContentModel, 6 | 'theme' | 'url' | 'title' | 'media' | 'content' | 'cardTitle' 7 | >; 8 | 9 | export type ContentFooterProps = { 10 | canShow: boolean; 11 | isNested?: boolean; 12 | onExpand: () => void; 13 | remainInterval: number; 14 | showMore: boolean; 15 | showReadMore?: boolean | ''; 16 | startWidth: number; 17 | textContentIsLarge: boolean; 18 | theme?: Theme; 19 | triangleDir?: string; 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/__tests__/__snapshots__/timeline-card-media.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Timeline Card media > should match the snapshot ( IMAGE ) 1`] = ` 4 | 5 | 9 | 13 | 23 | 24 | 25 | 29 | 32 | 35 | This is another test 36 | 37 | 38 | 39 | `; 40 | 41 | exports[`Timeline Card media > should match the snapshot ( VIDEO ) 1`] = ` 42 | 43 | 47 | 56 | 57 | 61 | 64 | 67 | This is another test 68 | 69 | 70 | 71 | `; 72 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { ErrorMessage as StyledErrorMessage } from '../timeline-card-media.styles'; 3 | 4 | interface ErrorMessageProps { 5 | message: string; 6 | } 7 | 8 | /** 9 | * Displays an error message when media fails to load 10 | */ 11 | export const LazyErrorMessage = memo(({ message }: ErrorMessageProps) => ( 12 | {message} 13 | )); 14 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/components/ImageDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { CardImage, ImageWrapper } from '../timeline-card-media.styles'; 3 | import { TimelineMode } from '@models/TimelineModel'; 4 | 5 | interface ImageDisplayProps { 6 | url: string; 7 | name: string; 8 | mode: TimelineMode; 9 | mediaLoaded: boolean; 10 | borderLessCards: boolean; 11 | mediaHeight: number; 12 | imageFit?: string; 13 | handleMediaLoaded: () => void; 14 | handleError: () => void; 15 | } 16 | 17 | /** 18 | * Renders an image with appropriate loading and error handling 19 | */ 20 | export const ImageDisplay = memo( 21 | ({ 22 | url, 23 | name, 24 | mode, 25 | mediaLoaded, 26 | borderLessCards, 27 | mediaHeight, 28 | imageFit, 29 | handleMediaLoaded, 30 | handleError, 31 | }: ImageDisplayProps) => ( 32 | 33 | 47 | 48 | ), 49 | ); 50 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/components/VideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { MediaState } from '@models/TimelineMediaModel'; 2 | import React, { memo, useEffect, useRef } from 'react'; 3 | import { CardVideo } from '../timeline-card-media.styles'; 4 | 5 | interface VideoPlayerProps { 6 | url: string; 7 | active: boolean; 8 | id: string; 9 | name: string; 10 | onMediaStateChange: (state: MediaState) => void; 11 | handleMediaLoaded: () => void; 12 | handleError: () => void; 13 | } 14 | 15 | /** 16 | * Renders a standard video player 17 | */ 18 | export const VideoPlayer = memo( 19 | ({ 20 | url, 21 | active, 22 | id, 23 | name, 24 | onMediaStateChange, 25 | handleMediaLoaded, 26 | handleError, 27 | }: VideoPlayerProps) => { 28 | const videoRef = useRef(null); 29 | 30 | useEffect(() => { 31 | if (!videoRef.current) return; 32 | 33 | if (active) { 34 | videoRef.current.play().catch(() => { 35 | // Handle autoplay failure silently 36 | }); 37 | } else { 38 | videoRef.current.pause(); 39 | } 40 | }, [active]); 41 | 42 | return ( 43 | 50 | onMediaStateChange({ 51 | id, 52 | paused: false, 53 | playing: true, 54 | }) 55 | } 56 | onPause={() => 57 | onMediaStateChange({ 58 | id, 59 | paused: true, 60 | playing: false, 61 | }) 62 | } 63 | onEnded={() => 64 | onMediaStateChange({ 65 | id, 66 | paused: false, 67 | playing: false, 68 | }) 69 | } 70 | onError={handleError} 71 | preload="metadata" 72 | > 73 | 74 | 75 | ); 76 | }, 77 | ); 78 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/components/YoutubePlayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { IFrameVideo } from '../timeline-card-media.styles'; 3 | 4 | interface YoutubePlayerProps { 5 | url: string; 6 | active: boolean; 7 | name: string; 8 | } 9 | 10 | /** 11 | * Renders a YouTube player iframe 12 | */ 13 | export const YoutubePlayer = memo( 14 | ({ url, active, name }: YoutubePlayerProps) => ( 15 | 24 | ), 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/hooks/useMediaLoad.ts: -------------------------------------------------------------------------------- 1 | import { MediaState } from '@models/TimelineMediaModel'; 2 | import { useState, useCallback } from 'react'; 3 | 4 | interface UseMediaLoadResult { 5 | loadFailed: boolean; 6 | mediaLoaded: boolean; 7 | handleMediaLoaded: () => void; 8 | handleError: () => void; 9 | } 10 | 11 | /** 12 | * Custom hook to handle media loading state and error handling 13 | * 14 | * @param id - The ID of the media element 15 | * @param onMediaStateChange - Callback to notify parent component of media state changes 16 | * @returns Object with loading state and handler functions 17 | */ 18 | export const useMediaLoad = ( 19 | id: string, 20 | onMediaStateChange: (state: MediaState) => void, 21 | ): UseMediaLoadResult => { 22 | const [loadFailed, setLoadFailed] = useState(false); 23 | const [mediaLoaded, setMediaLoaded] = useState(false); 24 | 25 | // This function will be invoked when the user has finished loading media 26 | const handleMediaLoaded = useCallback(() => { 27 | setMediaLoaded(true); 28 | }, []); 29 | 30 | // This function handles errors when loading media 31 | const handleError = useCallback(() => { 32 | setLoadFailed(true); 33 | onMediaStateChange({ 34 | id, 35 | paused: false, 36 | playing: false, 37 | }); 38 | }, [onMediaStateChange, id]); 39 | 40 | return { 41 | loadFailed, 42 | mediaLoaded, 43 | handleMediaLoaded, 44 | handleError, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/hooks/useToggleControls.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | /** 4 | * Custom hook to handle text display toggle functionality 5 | * @returns Object with toggle states and functions 6 | */ 7 | export const useToggleControls = () => { 8 | const [expandDetails, setExpandDetails] = useState(false); 9 | const [showText, setShowText] = useState(true); 10 | 11 | const toggleExpand = useCallback(() => { 12 | setExpandDetails((prev) => !prev); 13 | setShowText(true); 14 | }, []); 15 | 16 | const toggleText = useCallback(() => { 17 | setExpandDetails(false); 18 | setShowText((prev) => !prev); 19 | }, []); 20 | 21 | return { 22 | expandDetails, 23 | showText, 24 | toggleExpand, 25 | toggleText, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/hooks/useViewOptions.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode, useMemo, ForwardRefExoticComponent } from 'react'; 2 | import { hexToRGBA } from '@utils/index'; 3 | import { TextOrContentModel } from '../../timeline-card-content/text-or-content'; 4 | 5 | interface UseViewOptionsParams { 6 | showText: boolean; 7 | expandDetails: boolean; 8 | textOverlay: boolean; 9 | detailsText?: ForwardRefExoticComponent; 10 | title?: ReactNode; 11 | content?: ReactNode; 12 | theme: any; 13 | cardHeight: number; 14 | mediaHeight: number; 15 | } 16 | 17 | interface UseViewOptionsResult { 18 | canShowTextMemo: boolean; 19 | canShowTextContent: boolean; 20 | canExpand: boolean; 21 | gradientColor: string; 22 | canShowGradient: boolean; 23 | getCardHeight: number; 24 | } 25 | 26 | /** 27 | * Custom hook for handling view options and memoized calculated values 28 | */ 29 | export const useViewOptions = ({ 30 | showText, 31 | expandDetails, 32 | textOverlay, 33 | detailsText, 34 | title, 35 | content, 36 | theme, 37 | cardHeight, 38 | mediaHeight, 39 | }: UseViewOptionsParams): UseViewOptionsResult => { 40 | const canShowTextMemo = useMemo( 41 | () => showText && !!detailsText, 42 | [showText, detailsText], 43 | ); 44 | 45 | const canShowTextContent = useMemo( 46 | () => !!(title ?? content ?? detailsText), 47 | [title, content, detailsText], 48 | ); 49 | 50 | const canExpand = useMemo( 51 | () => textOverlay && !!detailsText, 52 | [textOverlay, detailsText], 53 | ); 54 | 55 | const gradientColor = useMemo( 56 | () => hexToRGBA(theme?.cardDetailsBackGround ?? '', 0.8), 57 | [theme?.cardDetailsBackGround], 58 | ); 59 | 60 | const canShowGradient = useMemo( 61 | () => !expandDetails && showText && textOverlay, 62 | [expandDetails, showText, textOverlay], 63 | ); 64 | 65 | const getCardHeight = useMemo( 66 | () => (textOverlay ? cardHeight : mediaHeight), 67 | [textOverlay, cardHeight, mediaHeight], 68 | ); 69 | 70 | return { 71 | canShowTextMemo, 72 | canShowTextContent, 73 | canExpand, 74 | gradientColor, 75 | canShowGradient, 76 | getCardHeight, 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/hooks/useYouTubeDetection.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | /** 4 | * Custom hook to detect YouTube URLs 5 | * @param url - The URL to check 6 | * @returns boolean indicating if the URL is a YouTube URL 7 | */ 8 | export const useYouTubeDetection = (url: string) => { 9 | return useMemo( 10 | () => /^(https?:\/\/)?(www\.youtube\.com|youtu\.?be)\/.+$/.test(url), 11 | [url], 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/timeline-card-media-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | const Button = css<{ theme: Theme }>` 5 | align-items: center; 6 | background: none; 7 | // background: rgba(0, 0, 0, 0.1); 8 | border-radius: 50%; 9 | border: none; 10 | cursor: pointer; 11 | display: flex; 12 | height: 1.5rem; 13 | justify-content: center; 14 | padding: 0; 15 | width: 1.5rem; 16 | margin: 0 0.25rem; 17 | background: ${(p) => p.theme?.primary}; 18 | color: #fff; 19 | 20 | svg { 21 | width: 70%; 22 | height: 70%; 23 | } 24 | `; 25 | 26 | export const ExpandButton = styled.button<{ 27 | // expandFull?: boolean; 28 | theme: Theme; 29 | }>` 30 | ${Button} 31 | `; 32 | 33 | export const ShowHideTextButton = styled.button<{ 34 | showText?: boolean; 35 | theme: Theme; 36 | }>` 37 | ${Button} 38 | `; 39 | 40 | export const ButtonWrapper = styled.ul` 41 | display: flex; 42 | flex-direction: row; 43 | justify-content: flex-end; 44 | list-style: none; 45 | margin: 0; 46 | padding: 0; 47 | margin-left: auto; 48 | `; 49 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card-media/video.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface VideoProps extends React.VideoHTMLAttributes { 5 | children: React.ReactNode; 6 | } 7 | 8 | const VideoElement = styled.video` 9 | width: 100%; 10 | height: auto; 11 | display: block; 12 | `; 13 | 14 | /** 15 | * Accessible Video component 16 | * Wraps the native video element with improved accessibility features 17 | */ 18 | const Video: React.FC = ({ 19 | children, 20 | autoPlay, 21 | muted, 22 | controls = true, 23 | onEnded, 24 | playsInline = true, 25 | ...rest 26 | }) => { 27 | const videoRef = useRef(null); 28 | 29 | // Ensure video is properly muted when needed 30 | useEffect(() => { 31 | if (videoRef.current && autoPlay) { 32 | videoRef.current.muted = true; 33 | } 34 | }, [autoPlay]); 35 | 36 | return ( 37 | 48 | {children} 49 | 50 | ); 51 | }; 52 | 53 | export default Video; 54 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card/__tests__/__snapshots__/timeline-horizontal-card.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`TimelineHorizontalCard > should match the snapshot 1`] = ` 4 | 5 | 9 | 12 | 22 | icon child 23 | 24 | 25 | 29 | 32 | 33 | 34 | 35 | `; 36 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card/hooks/useTimelineCard.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; 2 | import cls from 'classnames'; 3 | import { GlobalContext } from '../../../GlobalContext'; 4 | 5 | export const useTimelineCard = ({ 6 | active, 7 | autoScroll, 8 | slideShowRunning, 9 | cardLess, 10 | showAllCardsHorizontal, 11 | id, 12 | onClick, 13 | mode, 14 | position, 15 | iconChild, 16 | }) => { 17 | const circleRef = useRef(null); 18 | const wrapperRef = useRef(null); 19 | const contentRef = useRef(null); 20 | 21 | const { disableClickOnCircle } = useContext(GlobalContext); 22 | 23 | const handleClick = useCallback(() => { 24 | if (!disableClickOnCircle && onClick && !slideShowRunning) { 25 | onClick(id); 26 | } 27 | }, [disableClickOnCircle, onClick, slideShowRunning, id]); 28 | 29 | useEffect(() => { 30 | if (active) { 31 | const circle = circleRef.current; 32 | const wrapper = wrapperRef.current; 33 | 34 | if (circle && wrapper) { 35 | const circleOffsetLeft = circle.offsetLeft; 36 | const wrapperOffsetLeft = wrapper.offsetLeft; 37 | 38 | autoScroll?.({ 39 | pointOffset: circleOffsetLeft + wrapperOffsetLeft, 40 | pointWidth: circle.clientWidth, 41 | }); 42 | } 43 | } 44 | }, [active, autoScroll, mode]); 45 | 46 | const modeLower = useMemo(() => mode?.toLowerCase(), [mode]); 47 | 48 | const containerClass = useMemo( 49 | () => 50 | cls( 51 | 'timeline-horz-card-wrapper', 52 | modeLower, 53 | position === 'TOP' ? 'bottom' : 'top', 54 | showAllCardsHorizontal ? 'show-all' : '', 55 | ), 56 | [modeLower, position, showAllCardsHorizontal], 57 | ); 58 | 59 | const titleClass = useMemo( 60 | () => cls(modeLower, position), 61 | [modeLower, position], 62 | ); 63 | 64 | const circleClass = useMemo( 65 | () => 66 | cls( 67 | 'timeline-circle', 68 | { 'using-icon': !!iconChild }, 69 | modeLower, 70 | active ? 'active' : 'in-active', 71 | ), 72 | [active, iconChild, modeLower], 73 | ); 74 | 75 | const canShowTimelineContent = useMemo( 76 | () => (active && !cardLess) || showAllCardsHorizontal, 77 | [active, cardLess, showAllCardsHorizontal], 78 | ); 79 | 80 | return { 81 | circleRef, 82 | wrapperRef, 83 | contentRef, 84 | handleClick, 85 | modeLower, 86 | containerClass, 87 | titleClass, 88 | circleClass, 89 | canShowTimelineContent, 90 | }; 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-card/timeline-point/timeline-point.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Shape, ShapeWrapper } from '../timeline-horizontal-card.styles'; 3 | 4 | interface TimelinePointProps { 5 | circleClass: string; 6 | handleClick: () => void; 7 | circleRef: React.RefObject; 8 | title?: string; 9 | theme?: any; 10 | timelinePointDimension?: number; 11 | timelinePointShape?: 'circle' | 'square' | 'diamond'; 12 | iconChild?: React.ReactNode; 13 | active?: boolean; 14 | disabled?: boolean; 15 | } 16 | 17 | const TimelinePoint: React.FC = ({ 18 | circleClass, 19 | handleClick, 20 | circleRef, 21 | title, 22 | theme, 23 | timelinePointDimension, 24 | timelinePointShape, 25 | iconChild, 26 | active = false, 27 | disabled = false, 28 | }) => { 29 | return ( 30 | 31 | 47 | {iconChild ?? null} 48 | 49 | 50 | ) as React.ReactElement; 51 | }; 52 | 53 | export default TimelinePoint; 54 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-control/timeline-control.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ScreenReaderOnly = styled.div` 4 | position: absolute; 5 | width: 1px; 6 | height: 1px; 7 | padding: 0; 8 | margin: -1px; 9 | overflow: hidden; 10 | clip: rect(0, 0, 0, 0); 11 | white-space: nowrap; 12 | border-width: 0; 13 | `; 14 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-item-title/__tests__/__snapshots__/timeline-card-title.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Timeline item title > should match the snapshot 1`] = ` 4 | 5 | 8 | title 9 | 10 | 11 | `; 12 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-item-title/__tests__/timeline-card-title.test.tsx: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@testing-library/react'; 2 | import { describe } from 'vitest'; 3 | import { customRender } from '../../../common/test'; 4 | import { providerProps } from '../../../common/test/index'; 5 | import TimelineItemTitle from '../timeline-card-title'; 6 | 7 | describe('Timeline item title', () => { 8 | //should render the title 9 | it('should render the title', () => { 10 | const { getByText } = customRender(, { 11 | providerProps, 12 | }); 13 | expect(getByText('title')).toBeInTheDocument(); 14 | }); 15 | 16 | // should match the snapshot 17 | it('should match the snapshot', () => { 18 | const { container } = customRender(, { 19 | providerProps, 20 | }); 21 | 22 | expect(container).toMatchSnapshot(); 23 | }); 24 | 25 | // should render the title with active class 26 | it('should render the title with active class', () => { 27 | const { getByText } = customRender( 28 | , 29 | { providerProps }, 30 | ); 31 | expect(getByText('title')).toBeInTheDocument(); 32 | expect(getByText('title')).toHaveClass('active'); 33 | }); 34 | 35 | // should render the title with custom class 36 | it('should render the title with custom class', () => { 37 | const { getByText } = customRender( 38 | , 39 | { providerProps }, 40 | ); 41 | expect(getByText('title')).toBeInTheDocument(); 42 | expect(getByText('title')).toHaveClass('custom-class'); 43 | }); 44 | 45 | // should have a custom alignment 46 | it('should have a custom alignment', async () => { 47 | const { getByText } = customRender( 48 | , 49 | { providerProps }, 50 | ); 51 | expect(getByText('title')).toBeInTheDocument(); 52 | 53 | await waitFor(() => { 54 | expect(getByText('title')).toHaveProperty('align'); 55 | expect(getByText('title')).toHaveProperty('align', 'left'); 56 | // expect(getByText('title')).toHaveStyle('text-align: left'); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-item-title/timeline-card-title.styles.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import styled from 'styled-components'; 3 | 4 | export const TitleWrapper = styled.div<{ 5 | $fontSize?: string; 6 | $hide?: boolean; 7 | align?: string; 8 | theme?: Theme; 9 | }>` 10 | border-radius: 0.2rem; 11 | font-size: ${(p) => (p.$fontSize ? p.$fontSize : '1rem')}; 12 | font-weight: 600; 13 | overflow: hidden; 14 | padding: 0.25rem; 15 | visibility: ${(p) => (p.$hide ? 'hidden' : 'visible')}; 16 | text-align: ${(p) => (p.align ? p.align : '')}; 17 | color: ${(p) => (p.theme ? p.theme.titleColor : '')}; 18 | 19 | /* --- Prevent long text from affecting layout --- */ 20 | white-space: nowrap; /* Prevent text from wrapping to multiple lines */ 21 | text-overflow: ellipsis; /* Show ellipsis (...) for overflowing text */ 22 | min-width: 0; /* Allow the element to shrink below its content size */ 23 | max-width: 100%; /* Ensure it doesn't exceed its container */ 24 | 25 | &.active { 26 | background: ${(p) => p.theme?.secondary}; 27 | color: ${(p) => 28 | p.theme?.titleColorActive ? p.theme?.titleColorActive : p.theme?.primary}; 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-item-title/timeline-card-title.tsx: -------------------------------------------------------------------------------- 1 | import { TitleModel } from '@models/TimelineCardTitleModel'; 2 | import cls from 'classnames'; 3 | import React, { useContext, useMemo } from 'react'; 4 | import { GlobalContext } from '../../GlobalContext'; 5 | import { TitleWrapper } from './timeline-card-title.styles'; 6 | 7 | /** 8 | * TimelineItemTitle component 9 | * This component renders the title of a timeline item and applies appropriate styling based on the given props. 10 | * 11 | * @property {string} title - The text of the title. 12 | * @property {boolean} active - Indicates whether the title is active or not. 13 | * @property {Theme} theme - The theme object, used for styling. 14 | * @property {string} align - The alignment of the title. 15 | * @property {string} classString - Additional CSS classes for the title. 16 | * @returns {JSX.Element} The TimelineItemTitle component. 17 | */ 18 | const TimelineItemTitle: React.FunctionComponent = ({ 19 | title, 20 | active, 21 | theme, 22 | align, 23 | classString, 24 | }: TitleModel) => { 25 | const TITLE_CLASS = 'timeline-item-title'; // Base class name for the title 26 | 27 | // Computed class name for the title, combining base class, active state, and additional classes 28 | const titleClass = useMemo( 29 | () => cls(TITLE_CLASS, active ? 'active' : '', classString), 30 | [active, classString], 31 | ); 32 | 33 | // Get font size from global context 34 | const { fontSizes } = useContext(GlobalContext); 35 | 36 | return ( 37 | 44 | {title} 45 | 46 | ); 47 | }; 48 | 49 | export default TimelineItemTitle; 50 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-outline/animations.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from 'styled-components'; 2 | 3 | export const open = keyframes` 4 | from { 5 | width: 30px; 6 | height: 30px; 7 | opacity: 0.5; 8 | } 9 | 10 | to { 11 | width: 200px; 12 | height: 50%; 13 | opacity: 1; 14 | } 15 | `; 16 | 17 | export const close = keyframes` 18 | from { 19 | width: 200px; 20 | height: 50%; 21 | opacity: 1; 22 | } 23 | 24 | to { 25 | width: 30px; 26 | height: 30px; 27 | opacity: 0.5; 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-outline/hooks/useOutlinePosition.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { TimelineMode } from '@models/TimelineModel'; 3 | import { OutlinePosition } from '../timeline-outline.model'; 4 | 5 | /** 6 | * Custom hook to determine the outline position based on timeline mode 7 | * 8 | * @param mode - The current timeline mode 9 | * @returns The position of the outline 10 | */ 11 | export const useOutlinePosition = (mode?: TimelineMode): OutlinePosition => { 12 | return useMemo( 13 | () => 14 | mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING' 15 | ? OutlinePosition.right 16 | : OutlinePosition.left, 17 | [mode], 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/timeline-elements/timeline-outline/timeline-outline.model.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import { TimelineMode } from '@models/TimelineModel'; 3 | 4 | export enum OutlinePosition { 5 | 'left', 6 | 'right', 7 | } 8 | 9 | export interface TimelineOutlineModel { 10 | items?: TimelineOutlineItem[]; 11 | mode?: TimelineMode; 12 | onSelect?: (index: number) => void; 13 | theme?: Theme; 14 | isLoading?: boolean; 15 | error?: Error | null; 16 | onError?: (error: Error) => void; 17 | } 18 | 19 | export interface TimelineOutlineItem { 20 | id?: string; 21 | name?: string; 22 | selected?: boolean; 23 | disabled?: boolean; 24 | ariaLabel?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/timeline-horizontal/timeline-horizontal.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const TimelineHorizontalWrapper = styled.ul<{ flipLayout?: boolean }>` 4 | display: flex; 5 | list-style: none; 6 | margin: 0; 7 | width: 100%; 8 | direction: ${(p) => (p.flipLayout ? 'rtl' : 'ltr')}; 9 | 10 | &.vertical { 11 | flex-direction: column; 12 | } 13 | &.horizontal { 14 | flex-direction: row; 15 | } 16 | `; 17 | 18 | export const TimelineItemWrapper = styled.li<{ width: number }>` 19 | width: ${(p) => p.width}px; 20 | visibility: hidden; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | height: 150px; 25 | flex-direction: column; 26 | 27 | &.vertical { 28 | margin-bottom: 2rem; 29 | width: 100%; 30 | } 31 | 32 | &.visible { 33 | visibility: visible; 34 | } 35 | `; 36 | -------------------------------------------------------------------------------- /src/components/timeline-vertical/__tests__/__snapshots__/timeline-point.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Timeline point > Should match snapshot 1`] = ` 4 | 5 | 11 | 18 | 23 | 24 | 25 | 26 | `; 27 | -------------------------------------------------------------------------------- /src/components/timeline-vertical/__tests__/__snapshots__/timeline-vertical-item.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Timeline vertical item > Should match snapshot 1`] = ` 4 | 5 | 10 | 14 | 18 | vertical item title 19 | 20 | 21 | 24 | 31 | 34 | 38 | 42 | 52 | 53 | 54 | 55 | 56 | 61 | 68 | 73 | 74 | 75 | 76 | 77 | `; 78 | -------------------------------------------------------------------------------- /src/components/timeline-vertical/__tests__/timeline-point.test.tsx: -------------------------------------------------------------------------------- 1 | import { TimelinePointModel } from '@models/TimelineVerticalModel'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | import { customRender } from '../../common/test'; 4 | import { providerProps } from '../../common/test/index'; 5 | import { TimelinePoint } from '../timeline-point'; 6 | 7 | const commonProps: TimelinePointModel = { 8 | active: false, 9 | alternateCards: false, 10 | cardLess: true, 11 | className: 'test_class_name', 12 | disableClickOnCircle: false, 13 | iconChild: null, 14 | id: '1', 15 | lineWidth: 3, 16 | onActive: vi.fn(), 17 | onClick: vi.fn(), 18 | slideShowRunning: false, 19 | timelinePointDimension: 20, 20 | }; 21 | 22 | describe('Timeline point', () => { 23 | // should match the snapshot 24 | 25 | it('Should match snapshot', () => { 26 | const { asFragment } = customRender(, { 27 | providerProps, 28 | }); 29 | 30 | expect(asFragment()).toMatchSnapshot(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/timeline-vertical/__tests__/timeline-vertical-item.test.tsx: -------------------------------------------------------------------------------- 1 | import { VerticalItemModel } from '@models/TimelineVerticalModel'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { customRender } from '../../common/test'; 4 | import { providerProps } from '../../common/test/index'; 5 | import TimelineVerticalItem from '../timeline-vertical-item'; 6 | 7 | // Mock ResizeObserver 8 | global.ResizeObserver = class ResizeObserver { 9 | observe() {} 10 | unobserve() {} 11 | disconnect() {} 12 | }; 13 | 14 | const commonProps: VerticalItemModel = { 15 | // complete the rest of the properties 16 | active: false, 17 | alternateCards: false, 18 | cardDetailedText: '', 19 | cardSubtitle: '', 20 | cardTitle: '', 21 | className: '', 22 | contentDetailsChildren: null, 23 | hasFocus: false, 24 | iconChild: null, 25 | id: '', 26 | index: 1, 27 | media: { 28 | source: { 29 | type: 'IMAGE', 30 | url: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png', 31 | }, 32 | type: 'IMAGE', 33 | }, 34 | onActive: () => {}, 35 | onClick: () => {}, 36 | onElapsed: () => {}, 37 | slideShowRunning: false, 38 | timelineContent: null, 39 | title: 'vertical item title', 40 | url: '', 41 | visible: false, 42 | }; 43 | 44 | describe('Timeline vertical item', () => { 45 | it('Should match snapshot', () => { 46 | const { container } = customRender( 47 | , 48 | { providerProps }, 49 | ); 50 | 51 | expect(container).toMatchSnapshot(); 52 | }); 53 | 54 | //should render the title 55 | 56 | it('Should render the title', () => { 57 | const { getByText } = customRender( 58 | , 59 | { providerProps }, 60 | ); 61 | 62 | expect(getByText('vertical item title')).toBeInTheDocument(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/timeline/timeline-popover.model.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import { TextDensity, TimelineMode } from '@models/TimelineModel'; 3 | import { ListItemModel } from '../elements/list/list.model'; 4 | 5 | export type CommonProps = { 6 | isDarkMode: boolean; 7 | isMobile: boolean; 8 | position: 'top' | 'bottom'; 9 | theme: Theme; 10 | }; 11 | 12 | export type LayoutSwitcherProp = { 13 | initialTimelineMode?: TimelineMode | 'HORIZONTAL_ALL'; 14 | mode?: TimelineMode; 15 | onUpdateTimelineMode: (s: string) => void; 16 | } & CommonProps; 17 | 18 | export type QuickJumpProp = { 19 | activeItem: number; 20 | items: ListItemModel[]; 21 | onActivateItem: (id: string) => void; 22 | } & CommonProps; 23 | 24 | export type ChangeDensityProp = { 25 | onChange: (value: TextDensity) => void; 26 | selectedDensity: TextDensity; 27 | } & CommonProps; 28 | -------------------------------------------------------------------------------- /src/components/timeline/timeline-toolbar.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextDensity, 3 | TimelineMode, 4 | TimelineModel, 5 | TimelineProps, 6 | } from '@models/TimelineModel'; 7 | import { RefObject } from 'react'; 8 | 9 | export type TimelineToolbarProps = Pick< 10 | TimelineModel, 11 | | 'activeTimelineItem' 12 | | 'slideShowEnabled' 13 | | 'slideShowRunning' 14 | | 'onRestartSlideshow' 15 | | 'onNext' 16 | | 'onPrevious' 17 | | 'onPaused' 18 | | 'onFirst' 19 | | 'onLast' 20 | | 'items' 21 | | 'mode' 22 | > & { 23 | id: string; 24 | onActivateTimelineItem: (id: string) => void; 25 | onUpdateTextContentDensity: (value: TextDensity) => void; 26 | onUpdateTimelineMode: (mode: TimelineMode) => void; 27 | toggleDarkMode: () => void; 28 | totalItems: number; 29 | 30 | // Search related props 31 | searchQuery: string; 32 | onSearchChange: (query: string) => void; 33 | onClearSearch: () => void; 34 | onNextMatch: () => void; 35 | onPreviousMatch: () => void; 36 | totalMatches: number; 37 | currentMatchIndex: number; // 0-based index 38 | onSearchKeyDown?: (event: React.KeyboardEvent) => void; 39 | searchInputRef?: RefObject; // Ref for the search input 40 | } & Pick; 41 | -------------------------------------------------------------------------------- /src/components/toggle-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactNode, memo } from 'react'; 2 | import { useUIState } from '../../hooks/useUIState'; 3 | import { ButtonWrapper, ToggleSwitch } from './toggle-button.styles'; 4 | 5 | export interface ToggleButtonProps { 6 | offIcon: ReactNode; 7 | onIcon: ReactNode; 8 | state: boolean; 9 | onChange?: (state: boolean) => void; 10 | } 11 | 12 | const ToggleButton: FunctionComponent = memo( 13 | ({ offIcon, onIcon, state, onChange }) => { 14 | const { state: on, toggle } = useUIState(state ?? false); 15 | 16 | const handleToggle = () => { 17 | toggle(); 18 | onChange?.(on); 19 | }; 20 | 21 | return ( 22 | 23 | {on ? offIcon : onIcon} 24 | 25 | ); 26 | }, 27 | ); 28 | 29 | ToggleButton.displayName = 'ToggleButton'; 30 | 31 | export { ToggleButton }; 32 | -------------------------------------------------------------------------------- /src/components/toggle-button/toggle-button.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ButtonWrapper = styled.div``; 4 | 5 | export const ToggleSwitch = styled.span``; 6 | -------------------------------------------------------------------------------- /src/components/toolbar/__tests__/toolbar.test.tsx: -------------------------------------------------------------------------------- 1 | // Toolbar.test.tsx 2 | 3 | import { render } from '@testing-library/react'; 4 | import { getDefaultThemeOrDark } from '@utils/index'; 5 | import { ThemeProvider } from 'styled-components'; 6 | import { Toolbar } from '../index'; 7 | import { ToolbarItem } from '@models/ToolbarItem'; 8 | 9 | const items: ToolbarItem[] = [ 10 | { name: 'Item 1', onSelect: () => {}, id: '1' }, 11 | { name: 'Item 2', onSelect: () => {}, id: '2' }, 12 | ]; 13 | 14 | const theme = getDefaultThemeOrDark(); 15 | 16 | describe('Toolbar', () => { 17 | it('renders toolbar items', () => { 18 | const { getByText, baseElement } = render( 19 | 20 | 21 | {items.map((item, index) => ( 22 | {item.name} 23 | ))} 24 | 25 | , 26 | ); 27 | 28 | console.log(baseElement.innerHTML); 29 | 30 | expect(getByText(/Item 1/i)).toBeInTheDocument(); 31 | expect(getByText(/Item 2/i)).toBeInTheDocument(); 32 | }); 33 | 34 | it('renders icons', () => { 35 | const itemWithIcon = { 36 | icon: Icon, 37 | name: 'Icon Item', 38 | id: '3', 39 | onSelect: () => {}, 40 | }; 41 | 42 | const { getByText } = render( 43 | 44 | 45 | Content 46 | 47 | , 48 | ); 49 | 50 | expect(getByText(/Icon/i)).toBeInTheDocument(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/components/toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, memo } from 'react'; 2 | import { jsx as _jsx } from 'react/jsx-runtime'; 3 | import { 4 | ContentWrapper, 5 | IconWrapper, 6 | ToolbarListItem, 7 | ToolbarWrapper, 8 | } from './toolbar.styles'; 9 | import { ToolbarProps } from '@models/ToolbarProps'; 10 | 11 | /** 12 | * @description A reusable toolbar component that renders a list of items with icons and content 13 | * @component 14 | * @param {Object} props - Component properties 15 | * @param {Array} props.items - Array of toolbar items to render 16 | * @param {ReactNode[]} props.children - Child elements to render within each toolbar item 17 | * @param {Theme} props.theme - Theme configuration for styling 18 | * 19 | * @example 20 | * ```tsx 21 | * }]} 23 | * theme={theme} 24 | * > 25 | * 26 | * 27 | * ``` 28 | */ 29 | const Toolbar: FunctionComponent = memo( 30 | ({ items = [], children = [], theme }) => { 31 | if (!items.length) { 32 | return null; 33 | } 34 | 35 | return ( 36 | 37 | {items.map(({ label, id, icon }, index) => { 38 | if (!id) { 39 | console.warn('Toolbar item is missing required id property'); 40 | return null; 41 | } 42 | 43 | return ( 44 | 50 | {icon && {icon}} 51 | {children[index] && ( 52 | {children[index]} 53 | )} 54 | 55 | ); 56 | })} 57 | 58 | ); 59 | }, 60 | ); 61 | 62 | Toolbar.displayName = 'Toolbar'; 63 | 64 | export { Toolbar }; 65 | -------------------------------------------------------------------------------- /src/components/toolbar/toolbar.styles.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | // Base styles for flex containers - using memo to prevent recreation 5 | const flexContainer = css` 6 | display: flex; 7 | align-items: center; 8 | `; 9 | 10 | // Use transform instead of box-shadow for better performance 11 | export const ToolbarWrapper = styled.div<{ theme: Theme }>` 12 | ${flexContainer}; 13 | list-style: none; 14 | margin: 0; 15 | padding: 0.25rem; 16 | background-color: ${({ theme }) => theme.toolbarBgColor}; 17 | transform: translateY(0); 18 | filter: drop-shadow( 19 | 0 2px 4px ${({ theme }) => theme.shadowColor || 'rgba(0, 0, 0, 0.1)'} 20 | ); 21 | width: 100%; 22 | height: 100%; 23 | border-radius: 6px; 24 | flex-wrap: wrap; 25 | will-change: transform; 26 | border: ${({ theme }) => 27 | theme.buttonBorderColor ? `1px solid ${theme.buttonBorderColor}` : 'none'}; 28 | `; 29 | 30 | // Toolbar list item styles 31 | export const ToolbarListItem = styled.div` 32 | padding: 0; 33 | margin: 0 0.5rem; 34 | `; 35 | 36 | // Icon wrapper styles 37 | export const IconWrapper = styled.span` 38 | ${flexContainer}; 39 | justify-content: center; 40 | width: 1rem; 41 | height: 1rem; 42 | `; 43 | 44 | // Content wrapper styles 45 | export const ContentWrapper = styled.span` 46 | ${flexContainer}; 47 | `; 48 | -------------------------------------------------------------------------------- /src/demo/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | margin: 0 auto; 7 | } 8 | 9 | .App-logo { 10 | height: 40vmin; 11 | pointer-events: none; 12 | } 13 | 14 | @media (prefers-reduced-motion: no-preference) { 15 | .App-logo { 16 | animation: App-logo-spin infinite 20s linear; 17 | } 18 | } 19 | 20 | .App-header { 21 | background-color: #282c34; 22 | min-height: 100vh; 23 | display: flex; 24 | flex-direction: column; 25 | align-items: center; 26 | justify-content: center; 27 | font-size: calc(10px + 2vmin); 28 | color: white; 29 | } 30 | 31 | .App-link { 32 | color: #61dafb; 33 | } 34 | 35 | @keyframes App-logo-spin { 36 | from { 37 | transform: rotate(0deg); 38 | } 39 | to { 40 | transform: rotate(360deg); 41 | } 42 | } 43 | 44 | #root { 45 | display: flex; 46 | align-items: center; 47 | justify-items: center; 48 | min-height: 100vh; 49 | } 50 | 51 | .app-links { 52 | width: 100%; 53 | display: flex; 54 | align-items: flex-start; 55 | justify-content: center; 56 | } -------------------------------------------------------------------------------- /src/demo/components/horizontal/AllHorizontal.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainer, Horizontal } from '../../App.styles'; 5 | 6 | export interface AllHorizontalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const AllHorizontal: FunctionComponent = ({ 12 | items 13 | }) => { 14 | const [index, setIndex] = React.useState(-1); 15 | 16 | return ( 17 | 18 | 19 | {index} 20 | { 33 | setIndex(selected.index); 34 | }} 35 | timelinePointDimension={20} 36 | showAllCardsHorizontal 37 | activeItemIndex={8} 38 | > 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | }; -------------------------------------------------------------------------------- /src/demo/components/horizontal/BasicHorizontal.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent, useState } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainer, Horizontal } from '../../App.styles'; 5 | 6 | export interface BasicHorizontalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const BasicHorizontal: FunctionComponent = ({ 12 | items 13 | }) => { 14 | const [itemSelected, setItemSelected] = useState(0); 15 | 16 | return ( 17 | 18 | {itemSelected} 19 | 20 | setItemSelected(selected.index)} 30 | timelinePointDimension={20} 31 | timelinePointShape="square" 32 | parseDetailsAsHTML 33 | buttonTexts={{ 34 | first: 'Jump to First', 35 | last: 'Jump to Last', 36 | next: 'Next', 37 | previous: 'Previous', 38 | }} 39 | enableDarkToggle 40 | mediaSettings={{ 41 | align: 'center', 42 | fit: 'cover', 43 | }} 44 | > 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | }; -------------------------------------------------------------------------------- /src/demo/components/horizontal/CardlessHorizontal.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 5 | 6 | export interface CardlessHorizontalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const CardlessHorizontal: FunctionComponent = ({ 12 | type, 13 | items 14 | }) => ( 15 | 16 | 17 | console.log(selected.cardTitle)} 26 | /> 27 | 28 | 29 | ); -------------------------------------------------------------------------------- /src/demo/components/horizontal/InitialSelectedHorizontal.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainer, Horizontal } from '../../App.styles'; 5 | 6 | export interface InitialSelectedHorizontalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const InitialSelectedHorizontal: FunctionComponent = ({ 12 | items 13 | }) => ( 14 | 15 | 16 | console.log(selected)} 26 | > 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); -------------------------------------------------------------------------------- /src/demo/components/horizontal/index.ts: -------------------------------------------------------------------------------- 1 | // Horizontal timeline components 2 | export { BasicHorizontal } from './BasicHorizontal'; 3 | export { AllHorizontal } from './AllHorizontal'; 4 | export { CardlessHorizontal } from './CardlessHorizontal'; 5 | export { InitialSelectedHorizontal } from './InitialSelectedHorizontal'; 6 | 7 | export type { BasicHorizontalProps } from './BasicHorizontal'; 8 | export type { AllHorizontalProps } from './AllHorizontal'; 9 | export type { CardlessHorizontalProps } from './CardlessHorizontal'; 10 | export type { InitialSelectedHorizontalProps } from './InitialSelectedHorizontal'; -------------------------------------------------------------------------------- /src/demo/components/index.ts: -------------------------------------------------------------------------------- 1 | // Demo components exports 2 | export * from './vertical'; 3 | export * from './horizontal'; 4 | export { ThemeShowcase } from './ThemeShowcase'; -------------------------------------------------------------------------------- /src/demo/components/vertical/AlternatingVertical.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from '@models/Theme'; 2 | import { TimelineItemModel } from '@models/TimelineItemModel'; 3 | import React, { FunctionComponent } from 'react'; 4 | import Chrono from '../../../components'; 5 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 6 | 7 | export interface AlternatingVerticalProps { 8 | type: string; 9 | items: TimelineItemModel[]; 10 | theme: Theme; 11 | children?: React.ReactElement | React.ReactElement[]; 12 | } 13 | 14 | export const AlternatingVertical: FunctionComponent = ({ 15 | type, 16 | items, 17 | theme, 18 | children 19 | }) => ( 20 | 21 | 22 | console.log(selected)} 36 | onScrollEnd={() => console.log('end reached')} 37 | enableBreakPoint 38 | highlightCardsOnHover 39 | contentDetailsHeight={200} 40 | > 41 | {children} 42 | 43 | 44 | 45 | ); -------------------------------------------------------------------------------- /src/demo/components/vertical/BasicVertical.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 5 | 6 | export interface BasicVerticalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const BasicVertical: FunctionComponent = ({ 12 | type, 13 | items 14 | }) => ( 15 | 16 | 17 | console.log(selected.index)} 31 | fontSizes={{ 32 | title: '1.5rem', 33 | }} 34 | theme={{ 35 | cardDetailsColor: '#555555', 36 | }} 37 | focusActiveItemOnLoad 38 | activeItemIndex={2} 39 | cardHeight={200} 40 | contentDetailsHeight={10} 41 | timelinePointDimension={20} 42 | classNames={{ 43 | cardText: 'custom-text', 44 | }} 45 | mediaSettings={{ 46 | align: 'center', 47 | fit: 'cover', 48 | }} 49 | enableDarkToggle 50 | enableBreakPoint={true} 51 | responsiveBreakPoint={768} 52 | /> 53 | 54 | 55 | ); -------------------------------------------------------------------------------- /src/demo/components/vertical/CardlessVertical.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 5 | 6 | export interface CardlessVerticalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const CardlessVertical: FunctionComponent = ({ 12 | type, 13 | items 14 | }) => ( 15 | 16 | 17 | console.log(selected.cardTitle)} 26 | /> 27 | 28 | 29 | ); -------------------------------------------------------------------------------- /src/demo/components/vertical/MixedVertical.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import Chrono from '../../../components'; 3 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 4 | import { mixedTimeline } from '../../data'; 5 | 6 | export interface MixedVerticalProps { 7 | type: string; 8 | cardHeight?: number; 9 | } 10 | 11 | export const MixedVertical: FunctionComponent = ({ 12 | type, 13 | cardHeight 14 | }) => ( 15 | 16 | 17 | 28 | 29 | 30 | ); -------------------------------------------------------------------------------- /src/demo/components/vertical/NestedVertical.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 5 | 6 | export interface NestedVerticalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const NestedVertical: FunctionComponent = ({ 12 | type, 13 | items 14 | }) => ( 15 | 16 | 17 | console.log(selected.index)} 25 | fontSizes={{ 26 | title: '1rem', 27 | }} 28 | focusActiveItemOnLoad 29 | activeItemIndex={2} 30 | mediaHeight={200} 31 | nestedCardHeight={100} 32 | cardHeight={300} 33 | timelinePointDimension={20} 34 | classNames={{ 35 | cardText: 'custom-text', 36 | }} 37 | parseDetailsAsHTML 38 | enableDarkToggle 39 | mediaSettings={{ align: 'center' }} 40 | /> 41 | 42 | 43 | ); -------------------------------------------------------------------------------- /src/demo/components/vertical/NewMediaVertical.tsx: -------------------------------------------------------------------------------- 1 | import { TimelineItemModel } from '@models/TimelineItemModel'; 2 | import React, { FunctionComponent } from 'react'; 3 | import Chrono from '../../../components'; 4 | import { ComponentContainerTree, Vertical } from '../../App.styles'; 5 | 6 | export interface NewMediaVerticalProps { 7 | type: string; 8 | items: TimelineItemModel[]; 9 | } 10 | 11 | export const NewMediaVertical: FunctionComponent = ({ 12 | type, 13 | items 14 | }) => ( 15 | 16 | 17 | console.log(selected.index)} 28 | fontSizes={{ 29 | title: '1.5rem', 30 | }} 31 | theme={{ 32 | cardDetailsColor: '#2f4f4f', 33 | }} 34 | cardHeight={350} 35 | focusActiveItemOnLoad 36 | activeItemIndex={9} 37 | enableDarkToggle 38 | contentDetailsHeight={200} 39 | timelinePointDimension={20} 40 | classNames={{ 41 | cardText: 'custom-text', 42 | }} 43 | /> 44 | 45 | 46 | ); -------------------------------------------------------------------------------- /src/demo/components/vertical/index.ts: -------------------------------------------------------------------------------- 1 | // Vertical timeline components 2 | export { BasicVertical } from './BasicVertical'; 3 | export { AlternatingVertical } from './AlternatingVertical'; 4 | export { MixedVertical } from './MixedVertical'; 5 | export { NewMediaVertical } from './NewMediaVertical'; 6 | export { NestedVertical } from './NestedVertical'; 7 | export { AlternatingNestedVertical } from './AlternatingNestedVertical'; 8 | export { CardlessVertical } from './CardlessVertical'; 9 | export { CustomContentVertical } from './CustomContentVertical'; 10 | export { CustomContentWithIconsVertical } from './CustomContentWithIconsVertical'; 11 | 12 | export type { BasicVerticalProps } from './BasicVertical'; 13 | export type { AlternatingVerticalProps } from './AlternatingVertical'; 14 | export type { MixedVerticalProps } from './MixedVertical'; 15 | export type { NewMediaVerticalProps } from './NewMediaVertical'; 16 | export type { NestedVerticalProps } from './NestedVertical'; 17 | export type { AlternatingNestedVerticalProps } from './AlternatingNestedVertical'; 18 | export type { CardlessVerticalProps } from './CardlessVertical'; 19 | export type { CustomContentVerticalProps } from './CustomContentVertical'; 20 | export type { CustomContentWithIconsVerticalProps } from './CustomContentWithIconsVertical'; -------------------------------------------------------------------------------- /src/demo/data/index.ts: -------------------------------------------------------------------------------- 1 | // Centralized data exports 2 | export { basicTimeline } from './basic-timeline'; 3 | export { mixedTimeline } from './mixed-timeline'; 4 | export { nestedTimeline } from './nested-timeline'; 5 | export { worldHistoryTimeline } from './world-history'; -------------------------------------------------------------------------------- /src/demo/dynamic-load.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import Chrono from '../components'; 3 | import { basicTimeline } from './data'; 4 | 5 | export default function App() { 6 | const [pageIndex, setPageIndex] = useState(0); 7 | const [allItems, setAllItems] = useState([null]); 8 | const [items, setItems] = useState([null]); 9 | const [loading, setLoading] = useState(true); 10 | 11 | const handleAutoLoad = useCallback(() => { 12 | setLoading(false); 13 | 14 | if (items.length < 2) { 15 | return; 16 | } 17 | 18 | const newItems = [...items, ...allItems.splice(pageIndex * 2, 2)]; 19 | 20 | console.log('handleAutoLoad', { pageIndex, newItems }); 21 | 22 | setItems(newItems); 23 | }, [items.length, pageIndex]); 24 | 25 | useEffect(() => { 26 | const newAllItems = [...basicTimeline]; 27 | 28 | // console.log('newAllItems', newAllItems); 29 | 30 | setAllItems(newAllItems); 31 | setPageIndex(0); 32 | setItems(newAllItems.splice(0, 2)); 33 | }, []); 34 | 35 | useEffect(() => { 36 | if (loading) { 37 | handleAutoLoad(); 38 | } 39 | }, [loading, handleAutoLoad]); 40 | 41 | const handleLoadMore = useCallback(() => { 42 | if (items.length > 13) { 43 | return; 44 | } 45 | 46 | // console.log('handleLoadMore'); 47 | 48 | setPageIndex(pageIndex + 1); 49 | setLoading(true); 50 | }, [items.length]); 51 | 52 | // console.log('items', items); 53 | 54 | return ( 55 | 56 | 57 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/demo/layout.module.scss: -------------------------------------------------------------------------------- 1 | .center-column { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | height: 100%; 6 | } 7 | 8 | .column-left { 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: flex-start; 12 | align-items: flex-start; 13 | height: 100%; 14 | } 15 | 16 | .center { 17 | display: flex; 18 | flex-direction: row; 19 | height: 100%; 20 | } 21 | 22 | .center-left { 23 | display: flex; 24 | flex-direction: row; 25 | justify-content: flex-start; 26 | align-items: center; 27 | height: 100%; 28 | } 29 | 30 | .wrapper { 31 | @extend .center-column; 32 | width: 99%; 33 | min-height: 99vh; 34 | justify-content: flex-start; 35 | margin-left: auto; 36 | margin-right: auto; 37 | } 38 | 39 | .container { 40 | @extend .center; 41 | width: 100%; 42 | align-items: flex-start; 43 | } 44 | 45 | .aside { 46 | @extend .column-left; 47 | width: 300px; 48 | min-height: 500px; 49 | padding: 1rem; 50 | } 51 | 52 | .content { 53 | // @extend .center; 54 | display: flex; 55 | align-items: flex-start; 56 | border-left: 1px solid #ccc; 57 | width: calc(100% - 300px); 58 | justify-content: flex-start; 59 | padding: 1rem; 60 | } 61 | 62 | .header { 63 | height: 100px; 64 | @extend .center-left; 65 | border: 1px solid #ccc; 66 | width: 100%; 67 | padding-left: 1rem; 68 | } 69 | -------------------------------------------------------------------------------- /src/demo/layout.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import React from 'react'; 3 | import { NavLink, Outlet } from 'react-router-dom'; 4 | import styles from './layout.module.scss'; 5 | 6 | const items = [ 7 | { path: '/theme-showcase', label: 'Theme Showcase' }, 8 | { path: '/vertical-basic', label: 'Vertical Basic' }, 9 | { 10 | path: '/vertical-basic-nested', 11 | label: 'Vertical Basic Nested', 12 | hoverClass: 'hover:bg-blue-600', 13 | }, 14 | { path: '/vertical-world-history', label: 'Vertical World History' }, 15 | { path: '/vertical-alternating-mixed', label: 'Vertical Alternating Mixed' }, 16 | { 17 | path: '/vertical-alternating-nested', 18 | label: 'Vertical Alternating Nested', 19 | }, 20 | { path: '/vertical-alternating', label: 'Vertical Alternating' }, 21 | { path: '/horizontal', label: 'Horizontal' }, 22 | { path: '/horizontal-all', label: 'Horizontal All' }, 23 | { path: '/horizontal-initial-select', label: 'Horizontal Initial Select' }, 24 | { path: '/vertical-custom', label: 'Vertical Custom' }, 25 | { path: '/vertical-custom-icon', label: 'Vertical Custom Icon' }, 26 | { path: '/dynamic-load', label: 'Dynamic Load' }, 27 | { 28 | path: '/timeline-without-cards', 29 | label: 'Timeline Without Cards (Vertical)', 30 | }, 31 | { 32 | path: '/timeline-without-cards-horizontal', 33 | label: 'Timeline Without Cards (Horizontal)', 34 | }, 35 | ]; 36 | 37 | const Layout = () => { 38 | return ( 39 | 40 | 50 | 51 | React-Chrono (Kitchen Sink) 52 | 53 | 54 | 55 | 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | }; 76 | 77 | export { Layout }; 78 | -------------------------------------------------------------------------------- /src/examples/basic-horizontal.tsx: -------------------------------------------------------------------------------- 1 | import Chrono from '../components/index'; 2 | import timelineData from './data'; 3 | import React from 'react'; 4 | 5 | export default () => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/examples/basic-slideshow.tsx: -------------------------------------------------------------------------------- 1 | import Chrono from '../components/index'; 2 | import timelineData from './data'; 3 | import React from 'react'; 4 | 5 | export default () => ( 6 | 7 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/examples/basic-vertical.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Chrono from '../components/index'; 3 | import timelineData from './data'; 4 | 5 | export default () => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/examples/slideshow-with-overall-progress.tsx: -------------------------------------------------------------------------------- 1 | import Chrono from '../components/index'; 2 | import timelineData from './data'; 3 | import React from 'react'; 4 | 5 | export default () => ( 6 | 7 | 14 | 15 | ); -------------------------------------------------------------------------------- /src/hooks/__tests__/useBackground.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { renderHook } from '@testing-library/react'; 3 | import { useBackground } from '../useBackground'; 4 | import { hexToRGBA } from '../../utils'; 5 | 6 | // Mock the hexToRGBA function 7 | vi.mock('../../utils', () => { 8 | return { 9 | hexToRGBA: vi.fn(() => 'rgba(0,0,0,0.8)'), 10 | }; 11 | }); 12 | 13 | describe('useBackground', () => { 14 | const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 15 | 16 | beforeEach(() => { 17 | warnSpy.mockClear(); 18 | vi.mocked(hexToRGBA).mockClear(); 19 | }); 20 | 21 | afterEach(() => { 22 | warnSpy.mockReset(); 23 | vi.mocked(hexToRGBA).mockReset(); 24 | }); 25 | 26 | it('returns empty string if no color is provided', () => { 27 | const { result } = renderHook(() => useBackground()); 28 | expect(result.current).toBe(''); 29 | expect(hexToRGBA).not.toHaveBeenCalled(); 30 | }); 31 | 32 | it('returns empty string and warns if color is invalid hex', () => { 33 | const { result } = renderHook(() => useBackground('not-a-hex')); 34 | expect(result.current).toBe(''); 35 | expect(warnSpy).toHaveBeenCalledWith('Invalid hex color: not-a-hex'); 36 | expect(hexToRGBA).not.toHaveBeenCalled(); 37 | }); 38 | 39 | it('calls hexToRGBA and returns its value for valid hex', () => { 40 | vi.mocked(hexToRGBA).mockReturnValue('rgba(255,255,255,0.8)'); 41 | const { result } = renderHook(() => useBackground('#ffffff')); 42 | expect(hexToRGBA).toHaveBeenCalledWith('#ffffff', 0.8); 43 | expect(result.current).toBe('rgba(255,255,255,0.8)'); 44 | }); 45 | 46 | it('reacts to color and opacity changes', () => { 47 | vi.mocked(hexToRGBA) 48 | .mockReturnValueOnce('rgba(255,0,0,0.5)') 49 | .mockReturnValueOnce('rgba(255,0,0,1)'); 50 | 51 | const { result, rerender } = renderHook( 52 | ({ color, opacity }) => useBackground(color, opacity), 53 | { 54 | initialProps: { color: '#ff0000', opacity: 0.5 }, 55 | }, 56 | ); 57 | expect(result.current).toBe('rgba(255,0,0,0.5)'); 58 | rerender({ color: '#ff0000', opacity: 1 }); 59 | expect(result.current).toBe('rgba(255,0,0,1)'); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/hooks/__tests__/useUIState.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { useUIState } from '../useUIState'; 4 | 5 | describe('useUIState', () => { 6 | it('should initialize with the provided state', () => { 7 | const { result } = renderHook(() => useUIState(true)); 8 | 9 | expect(result.current.state).toBe(true); 10 | }); 11 | 12 | it('should toggle boolean state', () => { 13 | const { result } = renderHook(() => useUIState(true)); 14 | 15 | act(() => { 16 | result.current.toggle(); 17 | }); 18 | 19 | expect(result.current.state).toBe(false); 20 | 21 | act(() => { 22 | result.current.toggle(); 23 | }); 24 | 25 | expect(result.current.state).toBe(true); 26 | }); 27 | 28 | it('should set state to a specific value', () => { 29 | const { result } = renderHook(() => useUIState(true)); 30 | 31 | act(() => { 32 | result.current.setState(false); 33 | }); 34 | 35 | expect(result.current.state).toBe(false); 36 | 37 | act(() => { 38 | result.current.setState(true); 39 | }); 40 | 41 | expect(result.current.state).toBe(true); 42 | }); 43 | 44 | it('should work with different initial states', () => { 45 | const { result: resultTrue } = renderHook(() => useUIState(true)); 46 | const { result: resultFalse } = renderHook(() => 47 | useUIState(false), 48 | ); 49 | 50 | expect(resultTrue.current.state).toBe(true); 51 | expect(resultFalse.current.state).toBe(false); 52 | }); 53 | 54 | it('should maintain state between renders', () => { 55 | const { result, rerender } = renderHook(() => useUIState(true)); 56 | 57 | act(() => { 58 | result.current.toggle(); 59 | }); 60 | 61 | expect(result.current.state).toBe(false); 62 | 63 | rerender(); 64 | 65 | expect(result.current.state).toBe(false); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/hooks/useBackground.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { hexToRGBA } from '../utils'; 3 | 4 | const isValidHexColor = (color: string): boolean => { 5 | return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color); 6 | }; 7 | 8 | export const useBackground = (color?: string, opacity = 0.8): string => { 9 | return useMemo(() => { 10 | if (!color) return ''; 11 | if (!isValidHexColor(color)) { 12 | console.warn(`Invalid hex color: ${color}`); 13 | return ''; 14 | } 15 | return hexToRGBA(color, opacity); 16 | }, [color, opacity]); 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useEscapeKey.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useRef } from 'react'; 2 | 3 | interface UseEscapeKeyOptions { 4 | enabled?: boolean; 5 | key?: string; 6 | keyCode?: number; 7 | eventType?: 'keyup' | 'keydown' | 'keypress'; 8 | } 9 | 10 | /** 11 | * Hook that triggers callback on escape key press 12 | */ 13 | export default function useEscapeKey( 14 | callback: () => void, 15 | options: UseEscapeKeyOptions = {}, 16 | ) { 17 | const { 18 | enabled = true, 19 | key = 'Escape', 20 | keyCode = 27, 21 | eventType = 'keyup', 22 | } = options; 23 | 24 | const savedCallback = useRef(callback); 25 | 26 | useEffect(() => { 27 | savedCallback.current = callback; 28 | }, [callback]); 29 | 30 | const handleKey = useCallback( 31 | (e: KeyboardEvent) => { 32 | if (!enabled) return; 33 | 34 | if (e.key === key || e.keyCode === keyCode) { 35 | savedCallback.current(); 36 | } 37 | }, 38 | [enabled, key, keyCode], 39 | ); 40 | 41 | useEffect(() => { 42 | if (!enabled) return; 43 | 44 | document.addEventListener(eventType, handleKey); 45 | return () => { 46 | document.removeEventListener(eventType, handleKey); 47 | }; 48 | }, [eventType, handleKey, enabled]); 49 | } 50 | -------------------------------------------------------------------------------- /src/hooks/useMediaState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { MediaState } from '../models/TimelineMediaModel'; 3 | 4 | interface UseMediaStateProps { 5 | slideShowActive: boolean; 6 | paused: boolean; 7 | id?: string; 8 | onElapsed?: (id: string) => void; 9 | } 10 | 11 | interface UseMediaStateReturn { 12 | isPlaying: boolean; 13 | handleMediaState: (state: MediaState) => void; 14 | cleanup: () => void; 15 | } 16 | 17 | export const useMediaState = ({ 18 | slideShowActive, 19 | paused, 20 | id, 21 | onElapsed, 22 | }: UseMediaStateProps): UseMediaStateReturn => { 23 | const [isPlaying, setIsPlaying] = useState(false); 24 | 25 | const handleMediaState = useCallback( 26 | (state: MediaState) => { 27 | if (!slideShowActive) return; 28 | 29 | setIsPlaying(state.playing ?? false); 30 | 31 | if (state.paused && paused && id && onElapsed) { 32 | onElapsed(id); 33 | } 34 | }, 35 | [slideShowActive, paused, id, onElapsed], 36 | ); 37 | 38 | const cleanup = useCallback(() => { 39 | setIsPlaying(false); 40 | }, []); 41 | 42 | useEffect(() => { 43 | return cleanup; 44 | }, [cleanup]); 45 | 46 | return { 47 | isPlaying, 48 | handleMediaState, 49 | cleanup, 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useCallback, useRef } from 'react'; 2 | 3 | interface UseOutsideClickOptions { 4 | eventType?: 'click' | 'mousedown' | 'touchstart'; 5 | enabled?: boolean; 6 | } 7 | 8 | /** 9 | * Hook that triggers callback when clicking outside a referenced element 10 | */ 11 | export default function useOutsideClick( 12 | el: RefObject, 13 | callback: () => void, 14 | options: UseOutsideClickOptions = {}, 15 | ) { 16 | const { eventType = 'click', enabled = true } = options; 17 | const savedCallback = useRef(callback); 18 | 19 | useEffect(() => { 20 | savedCallback.current = callback; 21 | }, [callback]); 22 | 23 | const handleClick = useCallback( 24 | (e: MouseEvent | TouchEvent) => { 25 | if (!enabled) return; 26 | 27 | const element = el.current; 28 | if (element && !element.contains(e.target as Node)) { 29 | savedCallback.current(); 30 | } 31 | }, 32 | [el, enabled], 33 | ); 34 | 35 | useEffect(() => { 36 | if (!enabled) return; 37 | 38 | document.addEventListener(eventType, handleClick); 39 | return () => { 40 | document.removeEventListener(eventType, handleClick); 41 | }; 42 | }, [eventType, handleClick, enabled]); 43 | } 44 | -------------------------------------------------------------------------------- /src/hooks/useSlideshowProgress.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState, useRef } from 'react'; 2 | 3 | interface UseSlideshowProgressProps { 4 | /** Whether the slideshow is currently running */ 5 | slideShowRunning: boolean; 6 | /** Current active timeline item index */ 7 | activeTimelineItem: number; 8 | /** Total number of timeline items */ 9 | totalItems: number; 10 | /** Duration of each slide in milliseconds */ 11 | slideItemDuration: number; 12 | } 13 | 14 | interface SlideshowProgressState { 15 | /** Whether the slideshow progress is paused */ 16 | isPaused: boolean; 17 | /** Pause the slideshow progress */ 18 | pauseProgress: () => void; 19 | /** Resume the slideshow progress */ 20 | resumeProgress: () => void; 21 | } 22 | 23 | /** 24 | * Custom hook to manage overall slideshow progress state 25 | * This hook tracks the global progress across all timeline items during slideshow mode 26 | * 27 | * @param props - Configuration object containing slideshow state 28 | * @returns Object with pause/resume controls and current state 29 | */ 30 | export const useSlideshowProgress = ({ 31 | slideShowRunning, 32 | activeTimelineItem, 33 | totalItems, 34 | slideItemDuration, 35 | }: UseSlideshowProgressProps): SlideshowProgressState => { 36 | const [isPaused, setIsPaused] = useState(false); 37 | const pauseTimeoutRef = useRef(null); 38 | 39 | // Clean up timeout on unmount or when slideshow stops 40 | useEffect(() => { 41 | if (!slideShowRunning) { 42 | setIsPaused(false); 43 | if (pauseTimeoutRef.current) { 44 | clearTimeout(pauseTimeoutRef.current); 45 | pauseTimeoutRef.current = null; 46 | } 47 | } 48 | }, [slideShowRunning]); 49 | 50 | // Reset pause state when active item changes 51 | useEffect(() => { 52 | if (slideShowRunning) { 53 | setIsPaused(false); 54 | } 55 | }, [activeTimelineItem, slideShowRunning]); 56 | 57 | // Pause the progress 58 | const pauseProgress = useCallback(() => { 59 | if (slideShowRunning) { 60 | setIsPaused(true); 61 | } 62 | }, [slideShowRunning]); 63 | 64 | // Resume the progress 65 | const resumeProgress = useCallback(() => { 66 | if (slideShowRunning) { 67 | setIsPaused(false); 68 | } 69 | }, [slideShowRunning]); 70 | 71 | // Cleanup on unmount 72 | useEffect(() => { 73 | return () => { 74 | if (pauseTimeoutRef.current) { 75 | clearTimeout(pauseTimeoutRef.current); 76 | } 77 | }; 78 | }, []); 79 | 80 | return { 81 | isPaused, 82 | pauseProgress, 83 | resumeProgress, 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/hooks/useTimelineMedia.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import { 3 | toggleMediaVisibility, 4 | pauseVideoEmbeds, 5 | } from '../utils/timelineUtils'; 6 | 7 | interface UseTimelineMediaProps { 8 | mode: string; 9 | timelineMainRef: React.RefObject; 10 | } 11 | 12 | export const useTimelineMedia = ({ 13 | mode, 14 | timelineMainRef, 15 | }: UseTimelineMediaProps) => { 16 | const observer = useRef(null); 17 | 18 | // Setup IntersectionObserver for efficient media handling 19 | useEffect(() => { 20 | if (mode === 'HORIZONTAL') return; 21 | 22 | // Create observer first for better performance 23 | observer.current = new IntersectionObserver( 24 | (entries) => { 25 | entries.forEach((entry) => { 26 | const element = entry.target as HTMLDivElement; 27 | if (entry.isIntersecting) { 28 | toggleMediaVisibility(element, true); 29 | } else { 30 | toggleMediaVisibility(element, false); 31 | pauseVideoEmbeds(element); 32 | } 33 | }); 34 | }, 35 | { 36 | root: timelineMainRef.current, 37 | threshold: 0, 38 | }, 39 | ); 40 | 41 | // Use requestAnimationFrame to defer DOM operations 42 | requestAnimationFrame(() => { 43 | const element = timelineMainRef.current; 44 | if (!element) return; 45 | 46 | const childElements = element.querySelectorAll('.vertical-item-row'); 47 | childElements.forEach((elem) => { 48 | observer.current?.observe(elem); 49 | }); 50 | }); 51 | 52 | return () => { 53 | observer.current?.disconnect(); 54 | }; 55 | }, [mode, timelineMainRef]); 56 | 57 | return { 58 | observer, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /src/hooks/useTimelineMode.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { TimelineMode } from '@models/TimelineModel'; 3 | 4 | type ExtendedTimelineMode = TimelineMode | 'HORIZONTAL_ALL'; 5 | 6 | interface UseTimelineModeProps { 7 | initialMode: TimelineMode; 8 | showAllCardsHorizontal?: boolean; 9 | updateHorizontalAllCards?: (showAll: boolean) => void; 10 | } 11 | 12 | export const useTimelineMode = ({ 13 | initialMode, 14 | showAllCardsHorizontal = false, 15 | updateHorizontalAllCards, 16 | }: UseTimelineModeProps) => { 17 | const [timelineMode, setTimelineMode] = useState( 18 | initialMode === 'HORIZONTAL' && showAllCardsHorizontal 19 | ? 'HORIZONTAL_ALL' 20 | : initialMode, 21 | ); 22 | 23 | const handleTimelineUpdate = useCallback( 24 | (newMode: string) => { 25 | switch (newMode) { 26 | case 'VERTICAL': 27 | setTimelineMode('VERTICAL'); 28 | break; 29 | case 'HORIZONTAL': 30 | setTimelineMode('HORIZONTAL'); 31 | updateHorizontalAllCards?.(false); 32 | break; 33 | case 'VERTICAL_ALTERNATING': 34 | setTimelineMode('VERTICAL_ALTERNATING'); 35 | break; 36 | case 'HORIZONTAL_ALL': 37 | setTimelineMode('HORIZONTAL_ALL' as ExtendedTimelineMode); 38 | updateHorizontalAllCards?.(true); 39 | break; 40 | } 41 | }, 42 | [updateHorizontalAllCards], 43 | ); 44 | 45 | return { 46 | timelineMode, 47 | handleTimelineUpdate, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/hooks/useTimelineScroll.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | import { Scroll } from '@models/TimelineHorizontalModel'; 3 | 4 | interface UseTimelineScrollProps { 5 | mode: string; 6 | onScrollEnd?: () => void; 7 | setNewOffset: (element: HTMLDivElement, scroll: Partial) => void; 8 | } 9 | 10 | export const useTimelineScroll = ({ 11 | mode, 12 | onScrollEnd, 13 | setNewOffset, 14 | }: UseTimelineScrollProps) => { 15 | const timelineMainRef = useRef(null); 16 | const horizontalContentRef = useRef(null); 17 | 18 | // Handle scrolling 19 | const handleScroll = useCallback( 20 | (scroll: Partial) => { 21 | const element = timelineMainRef.current; 22 | if (element) { 23 | setNewOffset(element, scroll); 24 | } 25 | }, 26 | [setNewOffset], 27 | ); 28 | 29 | // Scroll handler for detecting end 30 | const handleMainScroll = useCallback( 31 | (ev: React.UIEvent) => { 32 | const target = ev.target as HTMLElement; 33 | 34 | if (mode === 'VERTICAL' || mode === 'VERTICAL_ALTERNATING') { 35 | const scrolled = target.scrollTop + target.clientHeight; 36 | if (target.scrollHeight - scrolled < 1) { 37 | onScrollEnd?.(); 38 | } 39 | } else { 40 | const scrolled = target.scrollLeft + target.offsetWidth; 41 | if (target.scrollWidth === scrolled) { 42 | onScrollEnd?.(); 43 | } 44 | } 45 | }, 46 | [mode, onScrollEnd], 47 | ); 48 | 49 | return { 50 | timelineMainRef, 51 | horizontalContentRef, 52 | handleScroll, 53 | handleMainScroll, 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/hooks/useUIState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | export interface UIStateHook { 4 | state: T; 5 | toggle: () => void; 6 | setState: (value: T) => void; 7 | } 8 | 9 | export const useUIState = ( 10 | initialState: T, 11 | ): UIStateHook => { 12 | const [state, setState] = useState(initialState); 13 | 14 | const toggle = useCallback(() => { 15 | setState((prev) => !prev as T); 16 | }, []); 17 | 18 | return { state, toggle, setState }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | interface WindowSize { 4 | width: number; 5 | height: number; 6 | } 7 | 8 | interface UseWindowSizeOptions { 9 | debounceMs?: number; 10 | } 11 | 12 | /** 13 | * Hook that returns the current window dimensions 14 | * Useful for responsive calculations and virtualization 15 | */ 16 | export const useWindowSize = ( 17 | options: UseWindowSizeOptions = {}, 18 | ): WindowSize => { 19 | const { debounceMs = 100 } = options; 20 | const [windowSize, setWindowSize] = useState({ 21 | width: typeof window !== 'undefined' ? window.innerWidth : 0, 22 | height: typeof window !== 'undefined' ? window.innerHeight : 0, 23 | }); 24 | 25 | const handleResize = useCallback(() => { 26 | let timeoutId: number | null = null; 27 | let frameId: number | null = null; 28 | 29 | const updateSize = () => { 30 | setWindowSize({ 31 | width: window.innerWidth, 32 | height: window.innerHeight, 33 | }); 34 | }; 35 | 36 | const debouncedUpdate = () => { 37 | if (timeoutId) { 38 | window.clearTimeout(timeoutId); 39 | } 40 | if (frameId) { 41 | window.cancelAnimationFrame(frameId); 42 | } 43 | 44 | timeoutId = window.setTimeout(() => { 45 | frameId = window.requestAnimationFrame(updateSize); 46 | }, debounceMs); 47 | }; 48 | 49 | debouncedUpdate(); 50 | 51 | return () => { 52 | if (timeoutId) { 53 | window.clearTimeout(timeoutId); 54 | } 55 | if (frameId) { 56 | window.cancelAnimationFrame(frameId); 57 | } 58 | }; 59 | }, [debounceMs]); 60 | 61 | useEffect(() => { 62 | window.addEventListener('resize', handleResize); 63 | handleResize(); 64 | 65 | return () => { 66 | window.removeEventListener('resize', handleResize); 67 | }; 68 | }, [handleResize]); 69 | 70 | return windowSize; 71 | }; 72 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | *, 6 | :after, 7 | :before { 8 | box-sizing: border-box; 9 | -webkit-box-sizing: border-box; 10 | } 11 | 12 | body { 13 | margin: 0; 14 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 15 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 16 | sans-serif; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | } 20 | 21 | code { 22 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 23 | monospace; 24 | } 25 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './demo/App'; 4 | import './index.css'; 5 | 6 | const Root = ReactDOM.createRoot(document.getElementById('root')!); 7 | 8 | Root.render(); 9 | -------------------------------------------------------------------------------- /src/models/Theme.ts: -------------------------------------------------------------------------------- 1 | export interface Theme { 2 | // card background color 3 | cardBgColor?: string; 4 | 5 | // card details background color 6 | cardDetailsBackGround?: string; 7 | 8 | // card details color 9 | cardDetailsColor?: string; 10 | 11 | cardMediaBgColor?: string; 12 | 13 | // card subtitle color 14 | cardSubtitleColor?: string; 15 | 16 | // card title color 17 | cardTitleColor?: string; 18 | 19 | // details color 20 | detailsColor?: string; 21 | 22 | // icon background color 23 | iconBackgroundColor?: string; 24 | 25 | // nested card background color 26 | nestedCardBgColor?: string; 27 | 28 | nestedCardDetailsBackGround?: string; 29 | 30 | // nested card details color 31 | nestedCardDetailsColor?: string; 32 | 33 | // nested card subtitle color 34 | nestedCardSubtitleColor?: string; 35 | 36 | // nested card title color 37 | nestedCardTitleColor?: string; 38 | 39 | // primary color 40 | primary?: string; 41 | 42 | // secondary color 43 | secondary?: string; 44 | 45 | // text color 46 | textColor?: string; 47 | 48 | // title color 49 | titleColor?: string; 50 | 51 | // title color for active tabs 52 | titleColorActive?: string; 53 | 54 | toolbarBgColor?: string; 55 | 56 | toolbarBtnBgColor?: string; 57 | 58 | toolbarTextColor?: string; 59 | 60 | timelineBgColor?: string; 61 | 62 | // === New Dark Mode Configurable Properties === 63 | 64 | // Icon colors for better visibility in dark mode 65 | iconColor?: string; 66 | 67 | // Button hover and active states 68 | buttonHoverBgColor?: string; 69 | buttonActiveBgColor?: string; 70 | buttonActiveIconColor?: string; 71 | 72 | // Border colors for enhanced contrast 73 | buttonBorderColor?: string; 74 | buttonHoverBorderColor?: string; 75 | buttonActiveBorderColor?: string; 76 | 77 | // Shadow and glow effects 78 | shadowColor?: string; 79 | glowColor?: string; 80 | 81 | // Search highlighting 82 | searchHighlightColor?: string; 83 | 84 | // Dark mode toggle specific styling 85 | darkToggleActiveBgColor?: string; 86 | darkToggleActiveIconColor?: string; 87 | darkToggleActiveBorderColor?: string; 88 | darkToggleGlowColor?: string; 89 | } 90 | -------------------------------------------------------------------------------- /src/models/TimelineCardTitleModel.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from './Theme'; 2 | import { ReactNode } from 'react'; 3 | 4 | /** 5 | * Represents the model for a title element. 6 | */ 7 | export interface TitleModel { 8 | // Indicates if the title is active. 9 | active?: boolean; 10 | 11 | // Alignment of the title (left or right). 12 | align?: 'left' | 'right'; 13 | 14 | // Additional CSS class string for styling. 15 | classString?: string; 16 | 17 | // Theme to be applied to the title element. 18 | theme?: Theme; 19 | 20 | // Text content of the title. 21 | title?: string | ReactNode; 22 | } 23 | -------------------------------------------------------------------------------- /src/models/TimelineContentModel.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { Theme } from './Theme'; 3 | import { TimelineItemModel } from './TimelineItemModel'; 4 | import { Media } from './TimelineMediaModel'; 5 | 6 | /** 7 | * Represents the model for timeline content. 8 | */ 9 | export type TimelineContentModel = { 10 | // Indicates if the content is active. 11 | active?: boolean; 12 | 13 | // Directory for branch-related content. 14 | branchDir?: string; 15 | 16 | cardTitle?: string | ReactNode; 17 | 18 | // Main content of the timeline item. 19 | content?: string | ReactNode; 20 | 21 | // Custom content for the timeline item. 22 | customContent?: React.ReactNode; 23 | 24 | // Detailed text for the timeline item. 25 | detailedText?: string | string[] | ReactNode | ReactNode[]; 26 | 27 | // Indicates if the timeline item should be flipped. 28 | flip?: boolean; 29 | 30 | // Indicates if the timeline item has focus. 31 | hasFocus?: boolean; 32 | 33 | // Unique identifier for the timeline item. 34 | id?: string; 35 | 36 | // Indicates if the timeline item is nested. 37 | isNested?: boolean; 38 | 39 | // Array of timeline items nested within this item. 40 | items?: TimelineItemModel[]; 41 | 42 | // Media associated with the timeline item. 43 | media?: Media; 44 | 45 | // Height of nested card within the item. 46 | nestedCardHeight?: number; 47 | 48 | // Click event handler for the timeline item. 49 | onClick?: (id: string) => void; 50 | 51 | // Elapsed event handler for the timeline item. 52 | onElapsed?: (id?: string) => void; 53 | 54 | // Show more event handler for the timeline item. 55 | onShowMore?: () => void; 56 | 57 | // Indicates if slide show is active. 58 | slideShowActive?: boolean; 59 | 60 | // Theme to be applied to the timeline item. 61 | theme?: Theme; 62 | 63 | // Custom content for the entire timeline content. 64 | timelineContent?: React.ReactNode; 65 | 66 | // Title of the timeline item. 67 | title?: string | ReactNode; 68 | 69 | // URL associated with the timeline item. 70 | url?: string; 71 | }; 72 | -------------------------------------------------------------------------------- /src/models/TimelineControlModel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the model for controlling the timeline. 3 | */ 4 | export interface TimelineControlModel { 5 | // Index of the active timeline item. 6 | activeTimelineItem?: number; 7 | 8 | // Indicates whether the left control is disabled. 9 | disableLeft?: boolean; 10 | 11 | // Indicates whether the right control is disabled. 12 | disableRight?: boolean; 13 | 14 | // Unique identifier for the control. 15 | id?: string; 16 | 17 | // Indicates whether the dark mode is enabled. 18 | isDark?: boolean; 19 | 20 | // Click event handler for moving to the first item. 21 | onFirst?: () => void; 22 | 23 | // Click event handler for moving to the last item. 24 | onLast?: () => void; 25 | 26 | // Click event handler for moving to the next item. 27 | onNext?: () => void; 28 | 29 | // Click event handler for pausing the slide show. 30 | onPaused?: () => void; 31 | 32 | // Click event handler for moving to the previous item. 33 | onPrevious?: () => void; 34 | 35 | // Click event handler for replaying the slide show. 36 | onReplay?: () => void; 37 | 38 | // Click event handler for toggling dark mode. 39 | onToggleDarkMode?: () => void; 40 | 41 | // Indicates whether slide show mode is enabled. 42 | slideShowEnabled?: boolean; 43 | 44 | // Indicates whether the slide show is currently running. 45 | slideShowRunning?: boolean; 46 | 47 | // Total number of items in the timeline. 48 | totalItems?: number; 49 | } 50 | -------------------------------------------------------------------------------- /src/models/TimelineHorizontalModel.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | // import { Theme } from './Theme'; 3 | import { TimelineCardModel } from './TimelineItemModel'; 4 | import { TimelineMode } from './TimelineModel'; 5 | 6 | /** 7 | * Represents the model for a horizontal timeline. 8 | */ 9 | export interface TimelineHorizontalModel { 10 | // Function to trigger auto-scrolling. 11 | autoScroll: (t: Partial) => void; 12 | 13 | // Children elements for the content details. 14 | contentDetailsChildren?: ReactNode | ReactNode[]; 15 | 16 | // Click event handler for timeline item click. 17 | handleItemClick: (id?: string) => void; 18 | 19 | // Indicates if the timeline has focus. 20 | hasFocus?: boolean; 21 | 22 | // Children elements for icons. 23 | iconChildren?: ReactNode; 24 | 25 | // Indicates if the timeline is nested. 26 | isNested?: boolean; 27 | 28 | // Width of each timeline item. 29 | itemWidth?: number; 30 | 31 | // Array of timeline card models. 32 | items: TimelineCardModel[]; 33 | 34 | // Mode of the timeline (horizontal or vertical). 35 | mode?: TimelineMode; 36 | 37 | // Height of nested card within the timeline item. 38 | nestedCardHeight?: number; 39 | 40 | // Elapsed event handler for timeline items. 41 | onElapsed?: (id?: string) => void; 42 | 43 | // Indicates if the slide show is running. 44 | slideShowRunning?: boolean; 45 | 46 | // Unique identifier for the timeline wrapper. 47 | wrapperId: string; 48 | } 49 | 50 | export interface Scroll { 51 | /** 52 | * Height of the Timeline card content 53 | * 54 | * @type {number} 55 | * @memberof Scroll 56 | */ 57 | contentHeight: number; 58 | 59 | /** 60 | * Offset of the Content card 61 | * 62 | * @type {number} 63 | * @memberof Scroll 64 | */ 65 | contentOffset: number; 66 | 67 | /** 68 | * Offset of the timeline point 69 | * 70 | * @type {number} 71 | * @memberof Scroll 72 | */ 73 | pointOffset: number; 74 | 75 | /** 76 | * Width of the timeline point 77 | * 78 | * @type {number} 79 | * @memberof Scroll 80 | */ 81 | pointWidth: number; 82 | 83 | /** 84 | * Height of the timeline point 85 | * 86 | * @type {number} 87 | * @memberof Scroll 88 | */ 89 | timelinePointHeight: number; 90 | } 91 | -------------------------------------------------------------------------------- /src/models/ToolbarItem.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type ToolbarItem = { 4 | /** 5 | * Icon to be displayed in the toolbar item. 6 | */ 7 | icon?: ReactNode; 8 | /** 9 | * Unique identifier for the toolbar item. 10 | */ 11 | id?: string; 12 | /** 13 | * Label text for the toolbar item. 14 | */ 15 | label?: string; 16 | /** 17 | * Name of the toolbar item. 18 | */ 19 | name: string; 20 | /** 21 | * Callback function to be called when the toolbar item is selected. 22 | * @param id - The id of the selected toolbar item. 23 | * @param name - The name of the selected toolbar item. 24 | */ 25 | onSelect: (id: string, name: string) => void; 26 | }; 27 | -------------------------------------------------------------------------------- /src/models/ToolbarProps.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { Theme } from '@models/Theme'; 3 | import { ToolbarItem } from './ToolbarItem'; 4 | 5 | export type ToolbarProps = { 6 | /** 7 | * Child nodes to be rendered within the toolbar. 8 | */ 9 | children?: ReactNode | ReactNode[]; 10 | /** 11 | * Array of toolbar items to be displayed. 12 | */ 13 | items?: ToolbarItem[]; 14 | /** 15 | * Theme settings for the toolbar. 16 | */ 17 | theme: Theme; 18 | }; 19 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/react-chrono.ts: -------------------------------------------------------------------------------- 1 | import ReactChrono from './components'; 2 | import { TimelineItemModel } from './models/TimelineItemModel'; 3 | 4 | export { ReactChrono as Chrono }; 5 | export type TimelineItem = TimelineItemModel; 6 | -------------------------------------------------------------------------------- /src/test-setup.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/dom'; 6 | import '@testing-library/jest-dom/vitest'; 7 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | // Fix for React 19 TypeScript compatibility issues 2 | import React from 'react'; 3 | 4 | declare global { 5 | namespace JSX { 6 | interface Element extends React.ReactElement {} 7 | } 8 | } 9 | 10 | // SCSS module declarations 11 | declare module '*.scss' { 12 | const classes: { [key: string]: string }; 13 | export default classes; 14 | } 15 | 16 | export {}; 17 | -------------------------------------------------------------------------------- /src/utils/mediaQueryUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Media query utility functions for handling responsive behaviors 3 | */ 4 | 5 | /** 6 | * Creates and returns a MediaQueryList object 7 | * @param query - CSS media query string (e.g. '(max-width: 768px)') 8 | * @returns MediaQueryList object or null if browser doesn't support matchMedia 9 | */ 10 | export function createMediaQuery(query: string): MediaQueryList | null { 11 | if (typeof window === 'undefined') return null; 12 | 13 | try { 14 | return window.matchMedia(query); 15 | } catch (error) { 16 | console.error('Error creating media query:', error); 17 | return null; 18 | } 19 | } 20 | 21 | /** 22 | * Adds change and resize listeners for the provided media query 23 | * @param currentMedia - MediaQueryList object to attach listeners to 24 | * @param handleMediaChange - Callback function for media query changes 25 | * @param handleResize - Callback function for window resize events 26 | */ 27 | export function addMediaListeners( 28 | currentMedia: MediaQueryList | null, 29 | handleMediaChange: (event: MediaQueryListEvent | MediaQueryList) => void, 30 | handleResize: () => void, 31 | ): void { 32 | if (!currentMedia || typeof window === 'undefined') return; 33 | 34 | try { 35 | currentMedia.addEventListener('change', handleMediaChange); 36 | window.addEventListener('resize', handleResize); 37 | } catch (error) { 38 | console.error('Error adding media listeners:', error); 39 | } 40 | } 41 | 42 | /** 43 | * Removes change and resize listeners to prevent memory leaks 44 | * @param currentMedia - MediaQueryList object to detach listeners from 45 | * @param handleMediaChange - Callback function to remove from media query changes 46 | * @param handleResize - Callback function to remove from window resize events 47 | */ 48 | export function removeMediaListeners( 49 | currentMedia: MediaQueryList | null, 50 | handleMediaChange: (event: MediaQueryListEvent | MediaQueryList) => void, 51 | handleResize: () => void, 52 | ): void { 53 | if (typeof window === 'undefined') return; 54 | 55 | try { 56 | if (currentMedia) { 57 | currentMedia.removeEventListener('change', handleMediaChange); 58 | } 59 | window.removeEventListener('resize', handleResize); 60 | } catch (error) { 61 | console.error('Error removing media listeners:', error); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/**/*.{jsx,tsx,ts,js}'], 4 | plugins: [], 5 | theme: { 6 | extend: {}, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "react-jsx", 16 | "noEmit": true, 17 | "rootDir": "src", 18 | "declaration": true, 19 | "declarationDir": "dist", 20 | "baseUrl": ".", 21 | "types": [ 22 | "node", 23 | "@types/react", 24 | "@types/react-dom", 25 | "@testing-library/jest-dom", 26 | "react-router-dom", 27 | ], 28 | "paths": { 29 | "@utils/*": ["src/utils/*"], 30 | "@models/*": ["src/models/*"], 31 | "@effects/*": ["src/effects/*"] 32 | }, 33 | "plugins": [ 34 | { 35 | "name": "typescript-plugin-css-modules" 36 | } 37 | ] 38 | }, 39 | "include": ["src/components", "src/models", "src/utils", "src/demo", "src/types", "types"], 40 | "exclude": [ 41 | "node_modules", 42 | "dist", 43 | "rollup.config.mjs", 44 | "cypress", 45 | "cypress.config.ts" 46 | ], 47 | "files": ["src/react-chrono.ts"] 48 | } 49 | -------------------------------------------------------------------------------- /types/static.d.ts: -------------------------------------------------------------------------------- 1 | /* Use this file to declare any custom file extensions for importing */ 2 | /* Use this folder to also add/extend a package d.ts file, if needed. */ 3 | 4 | declare module '*.css'; 5 | declare module '*.scss' { 6 | const classes: { [key: string]: string }; 7 | export default classes; 8 | } 9 | declare module '*.svg' { 10 | const ref: string; 11 | export default ref; 12 | } 13 | declare module '*.bmp' { 14 | const ref: string; 15 | export default ref; 16 | } 17 | declare module '*.gif' { 18 | const ref: string; 19 | export default ref; 20 | } 21 | declare module '*.jpg' { 22 | const ref: string; 23 | export default ref; 24 | } 25 | declare module '*.jpeg' { 26 | const ref: string; 27 | export default ref; 28 | } 29 | declare module '*.png' { 30 | const ref: string; 31 | export default ref; 32 | } 33 | declare module '*.webp' { 34 | const ref: string; 35 | export default ref; 36 | } 37 | declare module '*.avif' { 38 | const ref: string; 39 | export default ref; 40 | } 41 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | import tsconfig from 'vite-tsconfig-paths'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | outDir: 'dist_site', 8 | // Adding sourcemap for better debugging 9 | sourcemap: true, 10 | }, 11 | plugins: [react(), tsconfig()], 12 | root: './', 13 | server: { 14 | port: 4444, 15 | watch: { 16 | ignored: [ 17 | 'node_modules', 18 | 'dist', 19 | 'build', 20 | 'public', 21 | 'package.json', 22 | 'package-lock.json', 23 | 'tsconfig.json', 24 | 'vite.config.mts', 25 | 'yarn.lock', 26 | ], 27 | }, 28 | // Adding open option to automatically open the browser 29 | open: true, 30 | }, 31 | // Adding resolve.alias for better path management 32 | resolve: { 33 | alias: { 34 | '@components': '/src/components', 35 | '@models': '/src/models', 36 | '@utils': '/src/utils', 37 | }, 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import react from '@vitejs/plugin-react'; 4 | import tsconfig from 'vite-tsconfig-paths'; 5 | import { defineConfig } from 'vitest/config'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react(), tsconfig()], 10 | test: { 11 | coverage: { 12 | clean: true, 13 | enabled: false, 14 | exclude: [ 15 | // Type definitions and configuration files 16 | '**/*.d.ts', 17 | '**/*.config.{js,ts,mjs}', 18 | '**/tsconfig.json', 19 | '**/eslintrc.js', 20 | '**/eslint.config.*', 21 | '**/prettierrc', 22 | '**/postcss.config.js', 23 | '**/tailwind.config.js', 24 | '**/babel.config.js', 25 | '**/webpack.config.js', 26 | '**/rollup.config.*', 27 | '**/vite.config.*', 28 | '**/vitest.config.*', 29 | 30 | // Build and cache directories 31 | '**/node_modules/**', 32 | '**/dist/**', 33 | '**/build/**', 34 | '**/coverage/**', 35 | '**/cache/**', 36 | '**/test-build/**', 37 | '**/dist_site/**', 38 | 39 | // Documentation and assets 40 | '**/public/**', 41 | '**/readme-assets/**', 42 | '**/docs/**', 43 | 44 | // Test setup and configuration 45 | '**/test-setup.{js,ts}', 46 | '**/cypress/**', 47 | '**/test/**', 48 | 49 | // Source files that don't need coverage 50 | 'src/components/index.tsx', 51 | 'src/components/GlobalContext.tsx', 52 | 'src/react-chrono.ts', 53 | 'src/index.tsx', 54 | 'src/demo/**', 55 | 'src/examples/**', 56 | 'src/models/**', 57 | 'src/components/icons/**', 58 | 'src/types/**', 59 | 'src/styles/**', 60 | 61 | // Other common exclusions 62 | '**/.git/**', 63 | '**/.github/**', 64 | '**/.vscode/**', 65 | '**/.husky/**', 66 | '**/scripts/**', 67 | ], 68 | provider: 'v8', 69 | reporter: ['lcov', 'clover', 'html'], 70 | reportsDirectory: './coverage', 71 | }, 72 | environment: 'jsdom', 73 | globals: true, 74 | include: [ 75 | './src/components/**/*.test.{tsx,ts}', 76 | './src/utils/**/*.test.{tsx,ts}', 77 | './src/hooks/**/*.test.{tsx,ts}', 78 | ], 79 | minWorkers: 2, 80 | setupFiles: './src/test-setup.js', 81 | silent: false, 82 | update: true, 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | resolve: { 3 | alias: { 4 | "react-dom": "@hot-loader/react-dom", 5 | }, 6 | }, 7 | }; 8 | --------------------------------------------------------------------------------