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 && (
+
+ {items.map((current) => (
+
+ ))}
+
+ )}
+ >
+ );
};
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"
>
-
-
+
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
+
+
+
+
-
-
+
+
+
+ slide_indicator
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- slide_indicator
@@ -229,172 +179,180 @@ exports[`Shelf Component tests > Regular shelf 1`] = `
Test Shelf
-
-
-
-
+
+
-
-
- Movie 2
-
-
-
+
+ Movie 2
+
+
-
-
-
-
-
+
+
-
-
- Third movie
-
-
-
+
+ Third movie
+
+
-
-
-
-
-
+
+
-
-
- Last playlist item
-
-
-
+
+ Last playlist item
+
+
-
-
-
-
+
+
+
+
+
+ slide_indicator
+
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 (
-
- {Array.from({ length }, (_, pageIndex) => {
- return renderPaginationDots(index, pageIndex);
- })}
-
- );
- }
- };
-
- return (
-
-
- {showLeftControl && !!renderLeftControl &&
{renderLeftControl(() => slide('left'))}
}
-
- {wrapWithEmptyTiles ? (
-
- ) : null}
- {tileList.map((tile: Tile, listIndex) => {
- const isInView = !isMultiPage || (listIndex > tilesToShow - slideOffset && listIndex < tilesToShow * 2 + 1 - slideOffset);
-
- return (
- -
- {renderTile(tile.item, isInView)}
-
- );
- })}
- {wrapWithEmptyTiles ? (
-
- ) : null}
-
- {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
+
+
-
-
-
-
+
+
+
+
+
+ slide_indicator
+
@@ -130,92 +140,102 @@ exports[`Home Component tests > Home test 1`] = `
Second Playlist
-
-
-
-
+
+
-
-
- Other Vids
-
-
-
+
+ Other Vids
+
+
-
-
-
-
+
+
+
+
+
+ slide_indicator
+
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"