From e6fa98866b7c9b5790fdefbac7c04a74a85a5bbf Mon Sep 17 00:00:00 2001 From: Ricci Mwangela Date: Fri, 3 Nov 2023 09:17:31 +0000 Subject: [PATCH] backpack carousel --- examples/bpk-component-carousel/example.js | 14 +++ examples/bpk-component-carousel/stories.js | 11 ++ packages/bpk-component-carousel/README.md | 25 +++++ packages/bpk-component-carousel/index.ts | 21 ++++ .../src/BpkCarousel-test.tsx | 65 +++++++++++ .../src/BpkCarousel.d.ts | 35 ++++++ .../src/BpkCarousel.tsx | 35 ++++++ .../src/BpkCarouselContainer.module.css | 18 +++ .../src/BpkCarouselContainer.module.scss | 19 ++++ .../src/BpkCarouselContainer.tsx | 87 ++++++++++++++ .../src/BpkCarouselImage.module.css | 18 +++ .../src/BpkCarouselImage.module.scss | 28 +++++ .../src/BpkCarouselImage.tsx | 27 +++++ .../src/BpkCarouselSlidesIndicator.module.css | 18 +++ .../BpkCarouselSlidesIndicator.module.scss | 59 ++++++++++ .../src/BpkCarouselSlidesIndicator.tsx | 41 +++++++ .../__snapshots__/BpkCarousel-test.tsx.snap | 106 ++++++++++++++++++ .../src/accessibility-test.tsx | 49 ++++++++ packages/bpk-component-carousel/src/index.ts | 1 + packages/bpk-component-carousel/src/types.ts | 9 ++ packages/bpk-component-carousel/src/utils.tsx | 63 +++++++++++ 21 files changed, 749 insertions(+) create mode 100644 examples/bpk-component-carousel/example.js create mode 100644 examples/bpk-component-carousel/stories.js create mode 100644 packages/bpk-component-carousel/README.md create mode 100644 packages/bpk-component-carousel/index.ts create mode 100644 packages/bpk-component-carousel/src/BpkCarousel-test.tsx create mode 100644 packages/bpk-component-carousel/src/BpkCarousel.d.ts create mode 100644 packages/bpk-component-carousel/src/BpkCarousel.tsx create mode 100644 packages/bpk-component-carousel/src/BpkCarouselContainer.module.css create mode 100644 packages/bpk-component-carousel/src/BpkCarouselContainer.module.scss create mode 100644 packages/bpk-component-carousel/src/BpkCarouselContainer.tsx create mode 100644 packages/bpk-component-carousel/src/BpkCarouselImage.module.css create mode 100644 packages/bpk-component-carousel/src/BpkCarouselImage.module.scss create mode 100644 packages/bpk-component-carousel/src/BpkCarouselImage.tsx create mode 100644 packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.css create mode 100644 packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.scss create mode 100644 packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.tsx create mode 100644 packages/bpk-component-carousel/src/__snapshots__/BpkCarousel-test.tsx.snap create mode 100644 packages/bpk-component-carousel/src/accessibility-test.tsx create mode 100644 packages/bpk-component-carousel/src/index.ts create mode 100644 packages/bpk-component-carousel/src/types.ts create mode 100644 packages/bpk-component-carousel/src/utils.tsx diff --git a/examples/bpk-component-carousel/example.js b/examples/bpk-component-carousel/example.js new file mode 100644 index 0000000000..af77589bc3 --- /dev/null +++ b/examples/bpk-component-carousel/example.js @@ -0,0 +1,14 @@ +import BpkCarousel from '../../packages/bpk-component-carousel' + +const DemoImages = () => ( + +) + +const imagesList = [,, , , , ] + + +const DefaultExample = () => ( + +); + +export default DefaultExample diff --git a/examples/bpk-component-carousel/stories.js b/examples/bpk-component-carousel/stories.js new file mode 100644 index 0000000000..0d542b0fb9 --- /dev/null +++ b/examples/bpk-component-carousel/stories.js @@ -0,0 +1,11 @@ +import BpkCarousel from '../../packages/bpk-component-carousel' + +import DefaultExample from './example' + +export default { + title: 'bpk-component-carousel', + component: BpkCarousel, +}; + + +export const Default = DefaultExample; diff --git a/packages/bpk-component-carousel/README.md b/packages/bpk-component-carousel/README.md new file mode 100644 index 0000000000..0416feab8f --- /dev/null +++ b/packages/bpk-component-carousel/README.md @@ -0,0 +1,25 @@ +# bpk-component-boilerplate + +> Backpack carousel component. + +## Installation + +Check the main [Readme](https://github.com/skyscanner/backpack#usage) for a complete installation guide. + +## Usage + +```tsx +import BpkBoilerplate from '@skyscanner/backpack-web/bpk-component-carousel'; + +const imageChangeHandler = () => { + console.log('Image Changed') +} + +export default () => ( + ]} + initialImageIndex={2} + onImageChanged={imageChangeHandler} + /> + ); +``` diff --git a/packages/bpk-component-carousel/index.ts b/packages/bpk-component-carousel/index.ts new file mode 100644 index 0000000000..71058594bf --- /dev/null +++ b/packages/bpk-component-carousel/index.ts @@ -0,0 +1,21 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import BpkCarousel from "./src"; + +export default BpkCarousel diff --git a/packages/bpk-component-carousel/src/BpkCarousel-test.tsx b/packages/bpk-component-carousel/src/BpkCarousel-test.tsx new file mode 100644 index 0000000000..423a762d75 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarousel-test.tsx @@ -0,0 +1,65 @@ +import { render, screen, getAllByRole } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; + +import BpkCarousel from './BpkCarousel'; + + +describe('BpkCarousel', () => { + let images: ReactNode[] + + const DemoImages = () => ( + + ) + + function generateDemoImages(count: number) { + return Array.from({ length: count }, (_, i) => ()); + } + + type TestCase = [ + expectedCount: 7 | 5 | 4 | 12, + actualCount: number, + props: React.ComponentProps, + ]; + + beforeAll(() => { + + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + + (window as any).IntersectionObserver = class IntersectionObserver { + observe = jest.fn(); + + disconnect = jest.fn(); + + unobserve = jest.fn(); + }; + images = [, , , , ]; + }); + + it('should render correctly', () => { + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); + }); + + it.each([ + [12, 10, { images: generateDemoImages(10) }], + [7, 5, { images: generateDemoImages(5) }], + [5, 3, { images: generateDemoImages(3) }], + [4, 2, { images: generateDemoImages(2) }], + ])( + 'renders %i image(s) when there are %i image(s) available', + (expectedCount, actualCount, props) => { + render(); + + expect(screen.getByTestId('image-gallery-scroll-container').childElementCount).toBe(expectedCount); + }, + ); + + it('renders only one image when only one available (no fake images for the infinite scroll)', async () => { + render(); + + expect(screen.getByTestId('image-gallery-scroll-container').childElementCount).toBe(1); + }); +}); diff --git a/packages/bpk-component-carousel/src/BpkCarousel.d.ts b/packages/bpk-component-carousel/src/BpkCarousel.d.ts new file mode 100644 index 0000000000..75e561af5f --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarousel.d.ts @@ -0,0 +1,35 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2022 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OnImageChangedHandler } from "./types"; +import type { ReactNode } from 'react'; + +export type Props = { + images: ReactNode[] + initialImageIndex?: number; + onImageChanged?: OnImageChangedHandler +}; + +declare const BpkCarousel: { + ({ initialImageIndex, onImageChanged, ...rest }: Props): JSX.Element; + defaultProps: { + initialImageIndex: 0; + onImageChanged: null + }; +}; +export default BpkCarousel; diff --git a/packages/bpk-component-carousel/src/BpkCarousel.tsx b/packages/bpk-component-carousel/src/BpkCarousel.tsx new file mode 100644 index 0000000000..14225b4e97 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarousel.tsx @@ -0,0 +1,35 @@ +import { useRef, useState } from 'react'; + + +import { useScrollToInitialImage } from './utils'; +import BpkCarouselContainer from './BpkCarouselContainer'; +import BpkSlidesIndicator from './BpkCarouselSlidesIndicator'; +import type { Props } from './types'; + +const BpkCarousel = ({ + images, + initialImageIndex = 0, + onImageChanged = null +}: Props) => { + const [shownImageIndex, updateShownImageIndex] = useState(initialImageIndex); + const imagesRef = useRef>([]); + + useScrollToInitialImage(initialImageIndex, imagesRef); + + return ( + <> + + + + ); +}; + +export default BpkCarousel; diff --git a/packages/bpk-component-carousel/src/BpkCarouselContainer.module.css b/packages/bpk-component-carousel/src/BpkCarouselContainer.module.css new file mode 100644 index 0000000000..7dc4b859d0 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselContainer.module.css @@ -0,0 +1,18 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ +@keyframes bpk-keyframe-spin{100%{transform:rotate(1turn)}}.bpk-carousel-container{display:grid;width:100%;height:100%;grid:1fr / auto-flow 100%;border-radius:.75rem;overflow-x:auto;overflow-y:hidden;backface-visibility:hidden;overscroll-behavior:contain;scroll-snap-type:x mandatory;scrollbar-width:none;user-select:none}@media (max-width: 32.25rem){.bpk-carousel-container{border-radius:0}}.bpk-carousel-container::-webkit-scrollbar{display:none} diff --git a/packages/bpk-component-carousel/src/BpkCarouselContainer.module.scss b/packages/bpk-component-carousel/src/BpkCarouselContainer.module.scss new file mode 100644 index 0000000000..68fcfaa116 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselContainer.module.scss @@ -0,0 +1,19 @@ +@import '~bpk-mixins/index.scss'; + +.bpk-carousel-container { + display: grid; + width: 100%; + height: 100%; + grid: 1fr / auto-flow 100%; + overflow-x: auto; + overflow-y: hidden; + backface-visibility: hidden; + overscroll-behavior: contain; + scroll-snap-type: x mandatory; + scrollbar-width: none; + user-select: none; + + &::-webkit-scrollbar { + display: none; + } +} diff --git a/packages/bpk-component-carousel/src/BpkCarouselContainer.tsx b/packages/bpk-component-carousel/src/BpkCarouselContainer.tsx new file mode 100644 index 0000000000..394abec574 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselContainer.tsx @@ -0,0 +1,87 @@ +import type { MutableRefObject, ReactNode } from 'react'; +import { memo, useState } from 'react'; + +import { cssModules } from '../../bpk-react-utils'; + +import { useIntersectionObserver } from './utils'; +import STYLES from './BpkCarouselContainer.module.scss'; +import BpkCarouselImage from './BpkCarouselImage'; +import type { OnImageChangedHandler } from './types'; + +const getClassName = cssModules(STYLES); + +type Props = { + images: ReactNode[]; + onVisible: (visibleIndex: number) => void; + imagesRef: MutableRefObject>; + onImageChanged: OnImageChangedHandler +}; + +const BpkScrollContainer = memo(({ images, imagesRef, onImageChanged, onVisible }: Props) => { + const [root, setRoot] = useState(null); + const observeImageChange = useIntersectionObserver(onVisible, { + root, + threshold: 0.5, + }, onImageChanged); + const observeCycleScroll = useIntersectionObserver( + (index) => { + const imageElement = imagesRef.current && imagesRef.current[index]; + if (imageElement) { + imageElement.scrollIntoView({ + block: 'nearest', + inline: 'start', + }); + } + }, + { root, threshold: 1 }, + ); + + if (images.length === 1) { + return ( +
+ +
+ ); + } + + return ( +
+ { + observeCycleScroll(el); + observeImageChange(el); + }} + /> + {images.map((image, index) => ( + { + // eslint-disable-next-line no-param-reassign + imagesRef.current[index] = el; + observeImageChange(el); + }} + /> + ))} + { + observeCycleScroll(el); + observeImageChange(el); + }} + /> +
+ ); +}); + +export default BpkScrollContainer; diff --git a/packages/bpk-component-carousel/src/BpkCarouselImage.module.css b/packages/bpk-component-carousel/src/BpkCarouselImage.module.css new file mode 100644 index 0000000000..ba399650c8 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselImage.module.css @@ -0,0 +1,18 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ +@keyframes bpk-keyframe-spin{100%{transform:rotate(1turn)}}.bpk-carousel-image{display:inline-flex;width:100%;min-width:0;height:100%;min-height:0;justify-content:center;scroll-snap-align:start;scroll-snap-stop:always;isolation:isolate}.bpk-carousel-image img{max-width:100%;max-height:100%;border-radius:.75rem;object-fit:cover}@media (max-width: 32.25rem){.bpk-carousel-image img{border-radius:0;object-fit:contain}} diff --git a/packages/bpk-component-carousel/src/BpkCarouselImage.module.scss b/packages/bpk-component-carousel/src/BpkCarouselImage.module.scss new file mode 100644 index 0000000000..7d4dceb859 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselImage.module.scss @@ -0,0 +1,28 @@ +@import '~bpk-mixins/index.scss'; + +.bpk-carousel-image { + display: inline-flex; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + justify-content: center; + scroll-snap-align: start; + scroll-snap-stop: always; + + /* helps with flickering when cycle scroll */ + /* stylelint-disable-next-line order/properties-order */ + isolation: isolate; + + img { + max-width: 100%; + max-height: 100%; + border-radius: $bpk-border-radius-md; + object-fit: cover; + + @include bpk-breakpoint-mobile { + border-radius: 0; + object-fit: contain; + } + } +} diff --git a/packages/bpk-component-carousel/src/BpkCarouselImage.tsx b/packages/bpk-component-carousel/src/BpkCarouselImage.tsx new file mode 100644 index 0000000000..10eb03320c --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselImage.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import { forwardRef } from "react"; + +import { cssModules } from "../../bpk-react-utils"; + +import STYLES from "./BpkCarouselImage.module.scss" + +const getClassName = cssModules(STYLES); + +type ImageProps = { + image: ReactNode; + index: number; +}; +const BpkCarouselImage = forwardRef(({ image, index }, ref) => ( +
+ {image} +
+)); + +export default BpkCarouselImage + diff --git a/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.css b/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.css new file mode 100644 index 0000000000..9e22ee306b --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.css @@ -0,0 +1,18 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ +@keyframes bpk-keyframe-spin{100%{transform:rotate(1turn)}}.bpk-slides-indicator{position:relative;right:0;bottom:1.5rem;left:0;display:flex;margin:auto 1rem;overflow:hidden;overflow:clip}.bpk-slides-indicator__row{--direction: -1;display:inline-flex;margin:0 auto;flex-flow:row nowrap;align-items:center;transform:translateX(calc(var(--direction) * var(--scroll-index) * .75rem));transition:transform 200ms ease-in-out}html[dir='rtl'] .bpk-slides-indicator__row{--direction: 1}.bpk-slides-indicator__dot{width:.5rem;height:.5rem;flex:0 0 .5rem;transform:scale(0.5);transition:200ms ease-in;background-color:#fff;opacity:0.7;transform-origin:center;border-radius:.75rem}.bpk-slides-indicator__dot:not(:last-child){margin-right:.25rem}.bpk-slides-indicator__siblingToActive{transform:scale(0.75)}.bpk-slides-indicator__active{transform:none;opacity:1} diff --git a/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.scss b/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.scss new file mode 100644 index 0000000000..b02fa0d01c --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.module.scss @@ -0,0 +1,59 @@ +@import '~bpk-mixins/index.scss'; + +$dots-visible: 5; +$dot-size: $bpk-one-pixel-rem * 8; +$gap: $dot-size / 2; + +.bpk-carousel-slides-indicator { + position: relative; + right: 0; + bottom: 1.5rem; + left: 0; + display: flex; + margin: auto bpk-spacing-base(); + overflow: hidden; + overflow: clip; + + &__row { + --direction: -1; + + display: inline-flex; + margin: 0 auto; + flex-flow: row nowrap; + align-items: center; + transform: translateX( + calc(var(--direction) * var(--scroll-index) * #{$dot-size + $gap}) + ); + transition: transform $bpk-duration-sm ease-in-out; + + @include bpk-rtl { + --direction: 1; + } + } + + &__dot { + width: $dot-size; + height: $dot-size; + flex: 0 0 $dot-size; + transform: scale(0.5); + transition: $bpk-duration-sm ease-in; + background-color: $bpk-color-white; + opacity: 0.7; + transform-origin: center; + + @include bpk-border-radius-md; + + &:not(:last-child) { + margin-right: $gap; + } + } + + &__siblingToActive { + transform: scale(0.75); + } + + &__active { + transform: none; + opacity: 1; + } +} diff --git a/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.tsx b/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.tsx new file mode 100644 index 0000000000..d465dee963 --- /dev/null +++ b/packages/bpk-component-carousel/src/BpkCarouselSlidesIndicator.tsx @@ -0,0 +1,41 @@ +import { cssModules } from '../../bpk-react-utils'; + +import STYLES from './BpkCarouselSlidesIndicator.module.scss'; + +const getClassName = cssModules(STYLES); + +type Props = { + length: number; + activeIndex: number; +}; + +const BpkSCarousellidesIndicator = ({ activeIndex, length }: Props) => { + if (length < 2) return null; + + // scroll starting from 3 dot until last 5, unless there are less than 5 + const activeDotIndex = Math.min(Math.max(0, activeIndex - 2), Math.max(0, length - 5)); + + return ( +
+
+ {Array.from({ length }).map((_, index) => ( +
+ ))} +
+
+ ); +}; + +export default BpkSCarousellidesIndicator; diff --git a/packages/bpk-component-carousel/src/__snapshots__/BpkCarousel-test.tsx.snap b/packages/bpk-component-carousel/src/__snapshots__/BpkCarousel-test.tsx.snap new file mode 100644 index 0000000000..a66203f151 --- /dev/null +++ b/packages/bpk-component-carousel/src/__snapshots__/BpkCarousel-test.tsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BpkCarousel should render correctly 1`] = ` + + +