diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 85b12ee45..83bc5f33a 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -11,6 +11,7 @@ "dependencies": { "@adyen/adyen-web": "^5.42.1", "@inplayer-org/inplayer.js": "^3.13.24", + "@videodock/tile-slider": "^2.0.0-rc.2", "classnames": "^2.3.1", "date-fns": "^2.28.0", "dompurify": "^2.3.8", diff --git a/packages/ui-react/src/components/Shelf/Shelf.module.scss b/packages/ui-react/src/components/Shelf/Shelf.module.scss index 5a3473242..f6a120085 100644 --- a/packages/ui-react/src/components/Shelf/Shelf.module.scss +++ b/packages/ui-react/src/components/Shelf/Shelf.module.scss @@ -4,13 +4,9 @@ font-family: var(--body-alt-font-family); &:hover, - &:focus-within - { - .chevron { + &:focus-within { + .chevron:not(:disabled) { opacity: 1; - &.disabled { - opacity: 0.3; - } } } } @@ -28,6 +24,25 @@ text-overflow: ellipsis; } +.slider { + overflow: visible; + + :global(.TileSlider-list) { + li > a { + white-space: initial; + } + + li { + opacity: 1; + transition: opacity 0.1s ease; + + &:global(.TileSlider--hidden) { + opacity: 0.5; + } + } + } +} + .chevron { display: flex; flex-wrap: wrap; @@ -38,22 +53,31 @@ outline-color: var(--highlight-color, white); cursor: pointer; transition: transform 0.3s ease-out, opacity 0.3s ease-out; + appearance: none; > svg { width: 30px; height: 30px; } - &.disabled { - cursor: default; - &:hover { - transform: none; - } + + &:disabled { + opacity: 0.3; + pointer-events: none; } + &:hover { transform: scale(1.2); } } +.dots { + position: relative; + display: flex; + justify-content: center; + width: 100%; + margin-top: 12px; +} + .dot { display: inline-block; width: 10px; diff --git a/packages/ui-react/src/components/Shelf/Shelf.tsx b/packages/ui-react/src/components/Shelf/Shelf.tsx index eb89179b4..e58a1691d 100644 --- a/packages/ui-react/src/components/Shelf/Shelf.tsx +++ b/packages/ui-react/src/components/Shelf/Shelf.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; +import { CYCLE_MODE_RESTART, type RenderControl, type RenderPagination, TileSlider } from '@videodock/tile-slider'; import type { Playlist, PlaylistItem } from '@jwp/ott-common/types/playlist'; import type { AccessModel, ContentType } from '@jwp/ott-common/types/config'; import { isLocked } from '@jwp/ott-common/src/utils/entitlements'; @@ -10,8 +11,8 @@ import ChevronLeft from '@jwp/ott-theme/assets/icons/chevron_left.svg?react'; import ChevronRight from '@jwp/ott-theme/assets/icons/chevron_right.svg?react'; import useBreakpoint, { Breakpoint, type Breakpoints } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; import type { PosterAspectRatio } from '@jwp/ott-common/src/utils/collection'; +import '@videodock/tile-slider/lib/style.css'; -import TileDock from '../TileDock/TileDock'; import Card from '../Card/Card'; import Icon from '../Icon/Icon'; @@ -68,11 +69,10 @@ const Shelf = ({ }: ShelfProps) => { const breakpoint: Breakpoint = useBreakpoint(); const { t } = useTranslation('common'); - const [didSlideBefore, setDidSlideBefore] = useState(false); const tilesToShow: number = (featured ? featuredTileBreakpoints[breakpoint] : tileBreakpoints[breakpoint]) + visibleTilesDelta; const renderTile = useCallback( - (item: PlaylistItem, isInView: boolean) => { + ({ item, isVisible }: { item: PlaylistItem; isVisible: boolean }) => { const url = mediaURL({ media: item, playlistId: playlist.feedid, play: type === PersonalShelf.ContinueWatching }); return ( @@ -81,7 +81,7 @@ const Shelf = ({ progress={watchHistory ? watchHistory[item.mediaid] : undefined} onHover={typeof onCardHover === 'function' ? () => onCardHover(item) : undefined} featured={featured} - disabled={!isInView} + disabled={!isVisible} loading={loading} isLocked={isLocked(accessModel, isLoggedIn, hasSubscription, item)} posterAspect={posterAspect} @@ -93,53 +93,41 @@ const Shelf = ({ [watchHistory, onCardHover, featured, loading, accessModel, isLoggedIn, hasSubscription, posterAspect, playlist.feedid, type], ); - const renderRightControl = useCallback( - (doSlide: () => void) => ( -
(event.key === 'Enter' || event.key === ' ') && handleSlide(doSlide)} - onClick={() => handleSlide(doSlide)} - > + const renderRightControl: RenderControl = useCallback( + ({ onClick, disabled }) => ( +
+ ), [t], ); - const renderLeftControl = useCallback( - (doSlide: () => void) => ( -
(event.key === 'Enter' || event.key === ' ') && handleSlide(doSlide)} - onClick={() => handleSlide(doSlide)} - > + const renderLeftControl: RenderControl = useCallback( + ({ onClick, disabled }) => ( +
+ ), - [didSlideBefore, t], - ); - - const renderPaginationDots = (index: number, pageIndex: number) => ( - - ); - - const renderPageIndicator = (pageIndex: number, pages: number) => ( -
- {t('slide_indicator', { page: pageIndex + 1, pages })} -
+ [t], ); - const handleSlide = (doSlide: () => void): void => { - setDidSlideBefore(true); - doSlide(); + const renderPagination: RenderPagination = ({ page, pages }) => { + const items = Array.from({ length: pages }, (_, pageIndex) => pageIndex); + + return ( + <> +
+ {t('slide_indicator', { page: page + 1, pages })} +
+ {featured && ( + + )} + + ); }; if (error || !playlist?.playlist) return

Could not load items

; @@ -147,19 +135,16 @@ const Shelf = ({ return (
{!featured ?

{title || playlist.title}

: null} - - items={playlist.playlist} + + className={styles.slider} + items={playlist.playlist.slice(0, 5)} tilesToShow={tilesToShow} - wrapWithEmptyTiles={featured && playlist.playlist.length === 1} - cycleMode={'restart'} + cycleMode={CYCLE_MODE_RESTART} showControls={!loading} - showDots={featured} - transitionTime={'0.3s'} spacing={8} renderLeftControl={renderLeftControl} renderRightControl={renderRightControl} - renderPaginationDots={renderPaginationDots} - renderPageIndicator={renderPageIndicator} + renderPagination={renderPagination} renderTile={renderTile} />
diff --git a/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap b/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap index a2ed26d6c..9f47b4445 100644 --- a/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap +++ b/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap @@ -6,16 +6,15 @@ exports[`Shelf Component tests > Featured shelf 1`] = ` class="_shelf_81910b" >
-
-
+
- + + + +
-
-
+ +
+ + - - - @@ -229,172 +179,180 @@ exports[`Shelf Component tests > Regular shelf 1`] = ` Test Shelf
-
- - -
  • - +
  • +
  • -
    -

    - Movie 2 -

    -
    -
    +

    + Movie 2 +

    +
    - 1 min +
    + 1 min +
    - -
    -
  • -
  • - +
  • +
  • -
    -

    - Third movie -

    -
    -
    +

    + Third movie +

    +
    - 1 min +
    + 1 min +
    - -
    -
  • -
  • - +
  • +
  • -
    -

    - Last playlist item -

    -
    -
    +

    + Last playlist item +

    +
    - 21 min +
    + 21 min +
    - -
    -
  • - + + + + + diff --git a/packages/ui-react/src/components/TileDock/TileDock.module.scss b/packages/ui-react/src/components/TileDock/TileDock.module.scss deleted file mode 100644 index 73f0ace7a..000000000 --- a/packages/ui-react/src/components/TileDock/TileDock.module.scss +++ /dev/null @@ -1,49 +0,0 @@ -@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; - -.tileDock ul { - display: block; - margin: 0; - padding: 0; - white-space: nowrap; -} -.tileDock li { - display: inline-block; - white-space: normal; - vertical-align: top; - list-style-type: none; -} -.notInView { - opacity: 0.5; - @media (hover: hover) and (pointer: fine) { - opacity: 0.3; - } -} -.tileDock .leftControl { - position: absolute; - top: calc(50% + 22px); - left: 1px; - z-index: 1; - transform: translateY(-100%); -} -.tileDock .rightControl { - position: absolute; - top: calc(50% + 22px); - right: 1px; - z-index: 1; - transform: translateY(-100%); -} -.emptyTile::before { - content: ''; - display: block; - padding-top: 56.25%; - background: rgba(255, 255, 255, 0.12); - border-radius: 4px; -} - -.dots { - position: relative; - display: flex; - justify-content: center; - width: 100%; - margin-top: 12px; -} diff --git a/packages/ui-react/src/components/TileDock/TileDock.tsx b/packages/ui-react/src/components/TileDock/TileDock.tsx deleted file mode 100644 index 252e1b06b..000000000 --- a/packages/ui-react/src/components/TileDock/TileDock.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import classNames from 'classnames'; -import React, { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import styles from './TileDock.module.scss'; - -export type CycleMode = 'stop' | 'restart' | 'endless'; -type Direction = 'left' | 'right'; -type Position = { x: number; y: number }; - -export type TileDockProps = { - items: T[]; - cycleMode?: CycleMode; - tilesToShow?: number; - spacing?: number; - tileHeight?: number; - minimalTouchMovement?: number; - showControls?: boolean; - showDots?: boolean; - animationModeOverride?: boolean; - wrapWithEmptyTiles?: boolean; - transitionTime?: string; - renderTile: (item: T, isInView: boolean) => ReactNode; - renderLeftControl?: (handleClick: () => void) => ReactNode; - renderRightControl?: (handleClick: () => void) => ReactNode; - renderPaginationDots?: (index: number, pageIndex: number) => ReactNode; - renderPageIndicator?: (pageIndex: number, pages: number) => ReactNode; -}; - -type Tile = { - item: T; - key: string; -}; - -const makeTiles = (originalList: T[], slicedItems: T[]): Tile[] => { - const itemIndices: string[] = []; - - return slicedItems.map((item) => { - let key = `tile_${originalList.indexOf(item)}`; - while (itemIndices.includes(key)) { - key += '_'; - } - itemIndices.push(key); - return { item, key }; - }); -}; - -const sliceItems = (items: T[], isMultiPage: boolean, index: number, tilesToShow: number, cycleMode: CycleMode): Tile[] => { - if (!isMultiPage) return makeTiles(items, items); - - const sliceFrom: number = index; - const sliceTo: number = index + tilesToShow * 3; - - const cycleModeEndlessCompensation: number = cycleMode === 'endless' ? tilesToShow : 0; - const listStartClone: T[] = items.slice(0, tilesToShow + cycleModeEndlessCompensation + 1); - const listEndClone: T[] = items.slice(0 - (tilesToShow + cycleModeEndlessCompensation + 1)); - - const itemsWithClones: T[] = [...listEndClone, ...items, ...listStartClone]; - const itemsSlice: T[] = itemsWithClones.slice(sliceFrom, sliceTo + 2); - - return makeTiles(items, itemsSlice); -}; - -function TileDock({ - items, - tilesToShow = 6, - cycleMode = 'endless', - spacing = 12, - minimalTouchMovement = 30, - showControls = true, - animationModeOverride, - transitionTime = '0.6s', - wrapWithEmptyTiles = false, - showDots = false, - renderTile, - renderLeftControl, - renderRightControl, - renderPaginationDots, - renderPageIndicator, -}: TileDockProps) { - const [index, setIndex] = useState(0); - const [slideToIndex, setSlideToIndex] = useState(0); - const [transform, setTransform] = useState(-100); - // Prevent animation mode from changing after first load - const [isAnimated] = useState(animationModeOverride ?? !window.matchMedia('(prefers-reduced-motion)').matches); - const [isAnimationDone, setIsAnimationDone] = useState(false); - const [isAnimationRunning, setIsAnimationRunning] = useState(false); - - const frameRef = useRef() as React.MutableRefObject; - const tileWidth: number = 100 / tilesToShow; - const isMultiPage: boolean = items?.length > tilesToShow; - const transformWithOffset: number = isMultiPage ? 100 - tileWidth * (tilesToShow + 1) + transform : wrapWithEmptyTiles ? -100 : 0; - const pages = items.length / tilesToShow; - const tileList: Tile[] = useMemo(() => { - return sliceItems(items, isMultiPage, index, tilesToShow, cycleMode); - }, [items, isMultiPage, index, tilesToShow, cycleMode]); - - const transitionBasis: string = isMultiPage && isAnimated && isAnimationRunning ? `transform ${transitionTime} ease` : ''; - - const needControls: boolean = showControls && isMultiPage; - const showLeftControl: boolean = needControls && !(cycleMode === 'stop' && index === 0); - const showRightControl: boolean = needControls && !(cycleMode === 'stop' && index === items.length - tilesToShow); - - const slide = useCallback( - (direction: Direction): void => { - // Debounce slide events based on if the animation is running - if (isAnimationRunning) { - return; - } - - const directionFactor = direction === 'right' ? 1 : -1; - let nextIndex: number = index + tilesToShow * directionFactor; - - if (nextIndex < 0) { - if (cycleMode === 'stop') nextIndex = 0; - if (cycleMode === 'restart') nextIndex = index === 0 ? 0 - tilesToShow : 0; - } - - if (nextIndex > items.length - tilesToShow) { - if (cycleMode === 'stop') nextIndex = items.length - tilesToShow; - if (cycleMode === 'restart') nextIndex = index >= items.length - tilesToShow ? items.length : items.length - tilesToShow; - } - - const steps: number = Math.abs(index - nextIndex); - const movement: number = steps * tileWidth * (0 - directionFactor); - - setSlideToIndex(nextIndex); - setTransform(-100 + movement); - - // If this is an animated slider, start the animation 'slide' - if (isAnimated) { - setIsAnimationRunning(true); - } - // If not anmiated, trigger the post animation code right away - else { - setIsAnimationDone(true); - } - }, - [isAnimated, cycleMode, index, items.length, tileWidth, tilesToShow, isAnimationRunning], - ); - - const handleTouchStart = useCallback( - (event: React.TouchEvent): void => { - const touchPosition: Position = { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }; - - function handleTouchMove(this: Document, event: TouchEvent): void { - const newPosition: Position = { - x: event.changedTouches[0].clientX, - y: event.changedTouches[0].clientY, - }; - const movementX: number = Math.abs(newPosition.x - touchPosition.x); - const movementY: number = Math.abs(newPosition.y - touchPosition.y); - - if (movementX > movementY && movementX > 10) { - event.preventDefault(); - event.stopPropagation(); - } - } - - function handleTouchEnd(this: Document, event: TouchEvent): void { - const newPosition = { - x: event.changedTouches[0].clientX, - y: event.changedTouches[0].clientY, - }; - - const movementX: number = Math.abs(newPosition.x - touchPosition.x); - const movementY: number = Math.abs(newPosition.y - touchPosition.y); - const direction: Direction = newPosition.x < touchPosition.x ? 'right' : 'left'; - - if (movementX > minimalTouchMovement && movementX > movementY) { - slide(direction); - } - - cleanup(); - } - - function handleTouchCancel() { - cleanup(); - } - - function cleanup() { - document.removeEventListener('touchmove', handleTouchMove); - document.removeEventListener('touchend', handleTouchEnd); - document.removeEventListener('touchcancel', handleTouchCancel); - } - - document.addEventListener('touchmove', handleTouchMove, { passive: false }); - document.addEventListener('touchend', handleTouchEnd); - document.addEventListener('touchcancel', handleTouchCancel); - }, - [minimalTouchMovement, slide], - ); - - // Run code after the slide animation to set the new index - useLayoutEffect(() => { - const postAnimationCleanup = (): void => { - let resetIndex: number = slideToIndex; - - resetIndex = resetIndex >= items.length ? slideToIndex - items.length : resetIndex; - resetIndex = resetIndex < 0 ? items.length + slideToIndex : resetIndex; - - if (resetIndex !== slideToIndex) { - setSlideToIndex(resetIndex); - } - - setIndex(resetIndex); - setTransform(-100); - setIsAnimationRunning(false); - setIsAnimationDone(false); - }; - - if (isAnimationDone) { - postAnimationCleanup(); - } - }, [isAnimationDone, index, items.length, slideToIndex, tileWidth, tilesToShow, transitionBasis]); - - const handleTransitionEnd = (event: React.TransitionEvent) => { - if (event.target === frameRef.current) { - setIsAnimationDone(true); - } - }; - - const ulStyle = { - transform: `translate3d(${transformWithOffset}%, 0, 0)`, - // prettier-ignore - 'WebkitTransform': `translate3d(${transformWithOffset}%, 0, 0)`, - transition: transitionBasis, - marginLeft: -spacing / 2, - marginRight: -spacing / 2, - }; - - const slideOffset = index - slideToIndex; - - const paginationDots = () => { - if (showDots && isMultiPage && !!renderPaginationDots) { - const length = pages; - - // Using aria-hidden="true" due to virtualization issues, making pagination purely visual for now. This is a temporary fix pending a more accessible solution. - return ( - - ); - } - }; - - return ( - -
    - {showLeftControl && !!renderLeftControl &&
    {renderLeftControl(() => slide('left'))}
    } -
      - {wrapWithEmptyTiles ? ( -
    • - {renderTile(tile.item, isInView)} -
    • - ); - })} - {wrapWithEmptyTiles ? ( -
    - {showRightControl && !!renderRightControl &&
    {renderRightControl(() => slide('right'))}
    } -
    - {paginationDots()} - {isMultiPage && renderPageIndicator && renderPageIndicator(Math.ceil(index / tilesToShow), Math.ceil(pages))} -
    - ); -} - -export default TileDock; diff --git a/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap b/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap index ab576efc0..896df9568 100644 --- a/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap +++ b/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap @@ -27,92 +27,102 @@ exports[`Home Component tests > Home test 1`] = ` This is a playlist - - -
  • - +
  • +
  • -
    -

    - Other Vids -

    -
    -
    +

    + Other Vids +

    +
    - 6 min +
    + 6 min +
    - -
    -
  • - + + + + + @@ -130,92 +140,102 @@ exports[`Home Component tests > Home test 1`] = ` Second Playlist - - -
  • - +
  • +
  • -
    -

    - Other Vids -

    -
    -
    +

    + Other Vids +

    +
    - 6 min +
    + 6 min +
    - -
    -
  • - + + + + + diff --git a/yarn.lock b/yarn.lock index e8d77714d..b958fa497 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2871,6 +2871,11 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@videodock/tile-slider@^2.0.0-rc.2": + version "2.0.0-rc.2" + resolved "https://registry.yarnpkg.com/@videodock/tile-slider/-/tile-slider-2.0.0-rc.2.tgz#905619e538eecd226a4061a7c6c7f7f42a29aca4" + integrity sha512-3nVIDJ+aZ6HWDLiM2uHf7GiC3huy3GIji8HBTTTket3nhRdQ0Cx6rViHVcSw6pUIb736sORuCxU4f8VX/DV9tg== + "@vite-pwa/assets-generator@^0.2.3": version "0.2.4" resolved "https://registry.yarnpkg.com/@vite-pwa/assets-generator/-/assets-generator-0.2.4.tgz#ffd5dee762f6e98eaff9938fd52591cb04d8dbc7"