diff --git a/packages/plasma-core/package-lock.json b/packages/plasma-core/package-lock.json index 87644b7d5..0481b1bf8 100644 --- a/packages/plasma-core/package-lock.json +++ b/packages/plasma-core/package-lock.json @@ -3814,6 +3814,11 @@ "integrity": "sha512-2+R35uPD0skJjb4lxaDm3t8a1buKpU9pcNBQGidTLVD2Bxl5lrse2uDMV4flSjjoPOTpYq1CpMfkmt/BHLcl8Q==", "dev": true }, + "@sberdevices/use-virtual": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sberdevices/use-virtual/-/use-virtual-0.7.1.tgz", + "integrity": "sha512-j9n8qNi7J4l8YiNj6oH7Fo1f83PuCf+KpX5WfiCErjPD9dkx6QEuPNP+F3IDwKnzWUz+5/ojwnxeEOJ2LiX7wQ==" + }, "@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -5583,7 +5588,10 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/full-icu/-/full-icu-1.3.4.tgz", "integrity": "sha512-BERy9j2ybYSfP8QmXyg496NjVrGXfM73TZckQ5s5hgDV2lpKshjGfPEYYWU3hhE2kU8atZXUZNLSeNz4OQ8hNA==", - "dev": true + "dev": true, + "requires": { + "icu4c-data": "0.65.2" + } }, "function-bind": { "version": "1.1.1", @@ -5807,6 +5815,12 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "icu4c-data": { + "version": "0.65.2", + "resolved": "https://registry.npmjs.org/icu4c-data/-/icu4c-data-0.65.2.tgz", + "integrity": "sha512-c5LRnVavxDKGyRXkFWjZFrT/OpuixepdvihKwgbWoktNJ0t5X7ORyTQdFmq47cCzsmxcZWtaWqTMJR6S6N/3CQ==", + "dev": true + }, "import-local": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz", diff --git a/packages/plasma-core/package.json b/packages/plasma-core/package.json index 8d1c63316..36ab33bf4 100644 --- a/packages/plasma-core/package.json +++ b/packages/plasma-core/package.json @@ -7,18 +7,7 @@ "main": "index.js", "module": "es/index.js", "types": "index.d.ts", - "files": [ - "components", - "hocs", - "hooks", - "mixins", - "tokens", - "types", - "utils", - "index.d.ts", - "index.js", - "es" - ], + "files": ["components", "hocs", "hooks", "mixins", "tokens", "types", "utils", "index.d.ts", "index.js", "es"], "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1", @@ -63,13 +52,10 @@ "test": "NODE_ICU_DATA=node_modules/full-icu jest", "test:watch": "NODE_ICU_DATA=node_modules/full-icu jest --watch" }, - "contributors": [ - "Vasiliy Loginevskiy", - "Виноградов Антон Александрович", - "Зубаиров Фаниль Асхатович" - ], + "contributors": ["Vasiliy Loginevskiy", "Виноградов Антон Александрович", "Зубаиров Фаниль Асхатович"], "sideEffects": false, "dependencies": { + "@sberdevices/use-virtual": "0.7.1", "focus-visible": "5.2.0", "lodash.throttle": "4.1.1" } diff --git a/packages/plasma-core/src/components/VirtualCarousel/VirtualCarousel.tsx b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarousel.tsx new file mode 100644 index 000000000..2d38169f6 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarousel.tsx @@ -0,0 +1,100 @@ +import styled, { css } from 'styled-components'; + +import type { VirtualCarouselProps } from './types'; + +/** + * Компонент применяется, если требуется компенсировать отступы контейнера в сетке. + * При обертывании вокруг ``Carousel``, добавляет карусели и ее прокрутке дополнительные отступы. + * Стилизованный компонент, обладающий всеми свойствами ``div``. + */ +export const VirtualCarouselGridWrapper = styled.div` + overflow: hidden; + margin-left: calc(var(--plasma-grid-margin) * -1); + margin-right: calc(var(--plasma-grid-margin) * -1); +`; + +/** + * Корневой элемент - ограничивающая обертка карусели. + */ +export const VirtualCarousel = styled.div>` + position: relative; + ${({ carouselHeight, axis }) => css` + ${axis === 'y' ? 'height' : 'width'}: ${carouselHeight}px; + `} + /* stylelint-disable-next-line selector-max-empty-lines, selector-nested-pattern, selector-type-no-unknown */ + ::-webkit-scrollbar { + display: none; + } + + ${({ axis }) => + axis === 'x' + ? css` + overflow-x: auto; + overflow-y: hidden; + ` + : css` + overflow-x: hidden; + overflow-y: auto; + `} + + ${({ scrollSnapType, axis }) => + scrollSnapType && + scrollSnapType !== 'none' && + css` + //scroll-behavior: smooth; + //scroll-snap-type: ${axis} ${scrollSnapType}; + `} + + /* stylelint-disable-next-line */ + ${VirtualCarouselGridWrapper} & { + scroll-padding: 0 var(--plasma-grid-margin); + padding-left: var(--plasma-grid-margin); + } +`; + +/** + * Списковый (трековый) элемент карусели для непосредственного вложения айтемов в него. + */ +export const VirtualCarouselTrack = styled.div< + Pick +>` + position: relative; + ${({ carouselHeight, axis }) => css` + ${axis === 'x' ? 'width' : 'height'}: ${carouselHeight}px; + `} + ${({ axis, paddingStart, paddingEnd }) => + axis === 'x' + ? css` + //display: inline-flex; + //flex-direction: row; + + ${paddingStart && + css` + padding-left: ${paddingStart}; + `} + ${paddingEnd + ? css` + padding-right: ${paddingEnd}; + ` + : css` + /* stylelint-disable-next-line selector-nested-pattern */ + ${VirtualCarouselGridWrapper} & { + padding-right: var(--plasma-grid-margin); + } + `} + ` + : css` + display: flex; + flex-direction: column; + width: 100%; + + ${paddingStart && + css` + padding-top: ${paddingStart}; + `} + ${paddingEnd && + css` + padding-bottom: ${paddingEnd}; + `} + `} +`; diff --git a/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselContext.tsx b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselContext.tsx new file mode 100644 index 000000000..a9acd107f --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselContext.tsx @@ -0,0 +1,13 @@ +import { createContext } from 'react'; + +import { ScrollAxis } from './types'; + +export interface VirtualCarouselState { + axis: ScrollAxis; +} + +const initialValue: VirtualCarouselState = { + axis: 'x', +}; + +export const VirtualCarouselContext = createContext(initialValue); diff --git a/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItem.tsx b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItem.tsx new file mode 100644 index 000000000..bc5ca092f --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItem.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { applyScrollSnap, ScrollSnapProps } from '../../mixins'; +import type { AsProps } from '../../types'; + +export interface VirtualCarouselItemProps extends ScrollSnapProps, AsProps, React.HTMLAttributes { + /** + * Смещение по оси + */ + start: number; + /** + * Ось + */ + axis: string; +} + +const StyledItem = styled.div` + position: absolute; + top: 0; + left: 0; + ${applyScrollSnap} + ${({ start, axis }) => css` + transform: ${axis === 'x' ? 'translateX' : 'translateY'} (${start}px); + `} +`; + +export const VirtualCarouselItem: React.FC = ({ + scrollSnapAlign = 'center', + children, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/plasma-core/src/components/VirtualCarousel/index.ts b/packages/plasma-core/src/components/VirtualCarousel/index.ts new file mode 100644 index 000000000..5dc29c8a8 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/index.ts @@ -0,0 +1,10 @@ +export { VirtualCarouselGridWrapper } from './VirtualCarousel'; +export { VirtualCarousel } from './VirtualCarousel'; +export { VirtualCarouselTrack } from './VirtualCarousel'; + +export { VirtualCarouselContext } from './VirtualCarouselContext'; + +export { VirtualCarouselItem } from './VirtualCarouselItem'; +export type { VirtualCarouselItemProps } from './VirtualCarouselItem'; + +export type { VirtualCarouselProps } from './types'; diff --git a/packages/plasma-core/src/components/VirtualCarousel/types.tsx b/packages/plasma-core/src/components/VirtualCarousel/types.tsx new file mode 100644 index 000000000..8be3d7cf7 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/types.tsx @@ -0,0 +1,72 @@ +import type { HTMLAttributes } from 'react'; + +import type { AsProps, SnapType } from '../../types'; + +export type ScrollAxis = 'x' | 'y'; +export type ScrollAlign = 'start' | 'center' | 'end' | 'activeDirection'; + +export type ToIndex = (i: number) => void; +export type ToPrev = () => void; +export type ToNext = () => void; + +export interface BasicProps extends AsProps, HTMLAttributes { + /** + * Ось прокрутки + */ + axis: ScrollAxis; + /** + * Тип CSS Scroll Snap + */ + scrollSnapType?: SnapType; + /** + * Отступ в начале, используется при центрировании крайних элементов + */ + paddingStart?: string; + /** + * Отступ в конце, используется при центрировании крайних элементов + */ + paddingEnd?: string; + /** + * Обработчик события скролла + */ + onScroll?: HTMLAttributes['onScroll']; + + /** + * Количество всех элементов в списке. + */ + itemCount: number; + /** + * Вычисление размера элемента в зависимости от индекса. + * По умолчанию размер = 50px + */ + estimateSize: (index: number) => number; + /** + * количество элементов для рендера за видимой областью + * при скролле + */ + overscan?: number; + /** + * Функция для отрисовки элементов + * @param visibleItems - текущие элементы + * @param currentIndex - текущий выбранный индекс + */ + renderItems: (visibleItems: { index: number; start: number }[], currentIndex: number) => React.FC; + /** + * Высота карусели + */ + carouselHeight: number; +} +export interface DetectionProps { + /** + * Коллбек изменения индекса + */ + onIndexChange?: (index: number) => void; +} +export interface NoDetectionProps { + detectActive?: false; + detectThreshold?: never; + onIndexChange?: never; + scaleCallback?: never; + scaleResetCallback?: never; +} +export type VirtualCarouselProps = BasicProps & (DetectionProps | NoDetectionProps); diff --git a/packages/plasma-core/src/components/VirtualCarousel/utils.ts b/packages/plasma-core/src/components/VirtualCarousel/utils.ts new file mode 100644 index 000000000..bfe3a5dd0 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/utils.ts @@ -0,0 +1,57 @@ +import { ScrollAxis, ScrollAlign } from './types'; + +/** + * Подсчет смещения из-за паддингов. + */ +export const getCalculatedOffset = (scrollEl: Element, trackEl: Element, axis: ScrollAxis) => { + const paddingProp = axis === 'x' ? 'paddingLeft' : 'paddingTop'; + return parseInt(getComputedStyle(scrollEl)[paddingProp], 10) + parseInt(getComputedStyle(trackEl)[paddingProp], 10); +}; + +const round = (n: number) => Math.round(n * 100) / 100; + +/** + * Получить позицию (слот) айтема в каруселе. + * Каждый айтем имеет свой слот относительно вьюпорта карусели. + */ +export const getItemSlot = ( + itemIndex: number, + itemEnd: number, + itemSize: number, + scrollStart: number, + scrollSize: number, + scrollAlign: ScrollAlign, + prevIndex = 0, + offset = 0, +) => { + /** + * Граница и центр скролла (видимой части). + * Смещение + размер. + */ + const scrollEnd = scrollStart + scrollSize; + const scrollCenter = scrollStart + scrollSize / 2; + const itemCenter = itemEnd - itemSize / 2; + + if (scrollAlign === 'center') { + return round((itemCenter - scrollCenter) / itemSize); + } + if (scrollAlign === 'start') { + return round((itemEnd - itemSize - scrollStart) / itemSize); + } + if (scrollAlign === 'end') { + return round((itemEnd - (scrollSize + scrollStart)) / itemSize); + } + if (scrollAlign === 'activeDirection') { + const prevStart = offset + itemSize * prevIndex; + const prevEnd = prevStart + itemSize; + const prevVisible = prevEnd > scrollStart && prevStart < scrollEnd; + + if (!prevVisible) { + if (prevIndex < itemIndex) { + return round((itemEnd - (scrollSize + scrollStart)) / itemSize); + } + return round((itemEnd - itemSize - scrollStart) / itemSize); + } + } + return null; +}; diff --git a/packages/plasma-core/src/index.ts b/packages/plasma-core/src/index.ts index 02a5e026e..174be0305 100644 --- a/packages/plasma-core/src/index.ts +++ b/packages/plasma-core/src/index.ts @@ -22,6 +22,7 @@ export * from './components/Price'; export * from './components/Image'; export * from './components/Toast'; export * from './components/Typography'; +export * from './components/VirtualCarousel'; export * from './types'; export * from './tokens'; export * from './utils'; diff --git a/packages/plasma-ui/package-lock.json b/packages/plasma-ui/package-lock.json index 1f1937986..6c32da317 100644 --- a/packages/plasma-ui/package-lock.json +++ b/packages/plasma-ui/package-lock.json @@ -7578,6 +7578,11 @@ "resolved": "https://registry.npmjs.org/@sberdevices/plasma-typo/-/plasma-typo-0.3.0.tgz", "integrity": "sha512-2+R35uPD0skJjb4lxaDm3t8a1buKpU9pcNBQGidTLVD2Bxl5lrse2uDMV4flSjjoPOTpYq1CpMfkmt/BHLcl8Q==" }, + "@sberdevices/use-virtual": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@sberdevices/use-virtual/-/use-virtual-0.8.0.tgz", + "integrity": "sha512-9ooEgXNhNl7FU/+OnBfqDMUyD9zYPO4re+Qna9ks8y2WxLgP1oBXsjJyKkdmz1eIvFTjFVQhdfxsvOwWVlqyVw==" + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": false, diff --git a/packages/plasma-ui/package.json b/packages/plasma-ui/package.json index e61f21eaa..837cc686a 100644 --- a/packages/plasma-ui/package.json +++ b/packages/plasma-ui/package.json @@ -14,6 +14,7 @@ "dependencies": { "@sberdevices/plasma-core": "1.51.1", "@sberdevices/plasma-typo": "0.3.0", + "@sberdevices/use-virtual": "0.8.0", "color": "3.1.2", "lodash.throttle": "4.1.1", "react-draggable": "4.4.3" @@ -91,18 +92,7 @@ "^styled-components": "/node_modules/styled-components" } }, - "files": [ - "components", - "hocs", - "hooks", - "mixins", - "tokens", - "types", - "utils", - "index.d.ts", - "index.js", - "es" - ], + "files": ["components", "hocs", "hooks", "mixins", "tokens", "types", "utils", "index.d.ts", "index.js", "es"], "contributors": [ "Vasiliy Loginevskiy", "Антонов Игорь Александрович", diff --git a/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.examples.tsx b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.examples.tsx new file mode 100644 index 000000000..91bbacc59 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.examples.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import styled from 'styled-components'; +import { VirtualCarouselItemProps } from '@sberdevices/plasma-core'; + +import { MusicCard } from '../Card/Card.examples'; + +import { VirtualCarouselCol } from '.'; + +const scaleDelta = 0.37; + +const StyledColInner = styled.div` + transition: transform 0.1s ease 0s; +`; + +const StyledMusicCard = styled(MusicCard)` + transition: transform 0.1s ease 0s; +`; + +/** + * Функция сброса стилей элементов вне вьюпорта + */ +export const scaleResetCallback = (itemEl: HTMLDivElement) => { + if (itemEl.children[0]) { + const inner = itemEl.children[0] as HTMLDivElement; + const card = inner.children[0] as HTMLDivElement; + inner.style.transform = ''; + card.style.transform = ''; + } +}; + +/** + * Функция увеличения центрального элемента + */ +export const scaleCallback = (itemEl: HTMLDivElement, slot: number) => { + const absSlot = Math.abs(slot); + const scaleSlot = 1 - absSlot; + /** + * Чем ближе к центру - тем больше + */ + const cardScale = absSlot <= 1 ? 1 + scaleDelta * scaleSlot : 1; + const cardOffset = ((absSlot <= 1 ? scaleDelta * scaleSlot : 0) * itemEl.offsetHeight) / -3; + /** + * Чем дальше от центра - тем больше прозрачности + */ + const innerOffset = (scaleDelta * Math.min(absSlot, 1) * Math.sign(slot) * itemEl.offsetWidth) / 2; + + if (itemEl.children[0]) { + const inner = itemEl.children[0] as HTMLDivElement; + const card = inner.children[0] as HTMLDivElement; + inner.style.transform = `translate3d(${innerOffset}px,0,0)`; + card.style.transform = `scale(${cardScale}) translate3d(0,${cardOffset}px,0)`; + } +}; + +export interface ScalingColCardProps extends Omit { + isActive: boolean; + item: { + title: string; + imageSrc: string; + }; +} + +export const ScalingColCard: React.FC = ({ isActive, scrollSnapAlign, item, ...rest }) => ( + + + + + +); diff --git a/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.hooks.tsx b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.hooks.tsx new file mode 100644 index 000000000..e8556a992 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.hooks.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { ScrollAxis } from '@sberdevices/plasma-core'; + +import { useRemoteListener } from '../../hooks'; + +import { throttleByFrames } from './utils'; + +const throttlingParamsDefault = { + leading: true, + trailing: false, +}; + +/** + * Хук для навигации. Слушает нажатие кнопок на пульте/клавиатуре. + */ +export function useRemoteHandlers({ + initialIndex = 0, + axis, + delayFrames, + longDelayFrames, + min, + max, + count = 1, + longCount = 5, +}: { + initialIndex: number; + axis: ScrollAxis; + delayFrames: number; + longDelayFrames: number; + min: number; + max: number; + count?: number; + longCount?: number; + throttlingParams?: typeof throttlingParamsDefault; +}) { + const indexState = React.useState(initialIndex); + const [, setIndex] = indexState; + + const step = React.useCallback( + throttleByFrames( + (cmd: '+' | '-') => + setIndex((prevIndex) => { + if (cmd === '+') { + return prevIndex + count <= max ? prevIndex + count : min; + } + return prevIndex - count >= min ? prevIndex - count : max; + }), + delayFrames, + ), + [min, max], + ); + const jump = React.useCallback( + throttleByFrames( + (cmd: '+' | '-') => + setIndex((prevIndex) => { + if (cmd === '+') { + return prevIndex + longCount <= max ? prevIndex + longCount : min; + } + return prevIndex - longCount >= min ? prevIndex - longCount : max; + }), + longDelayFrames, + ), + [min, max], + ); + + useRemoteListener((key, ev) => { + ev.preventDefault(); + if (axis === 'x') { + switch (key) { + case 'LEFT': + step('-'); + break; + case 'RIGHT': + step('+'); + break; + case 'LONG_LEFT': + jump('-'); + break; + case 'LONG_RIGHT': + jump('+'); + break; + default: + break; + } + } else { + switch (key) { + case 'UP': + step('-'); + break; + case 'DOWN': + step('+'); + break; + case 'LONG_UP': + jump('-'); + break; + case 'LONG_DOWN': + jump('+'); + break; + default: + break; + } + } + }); + + return indexState; +} diff --git a/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.mdx b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.mdx new file mode 100644 index 000000000..1a5d8a772 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.mdx @@ -0,0 +1,27 @@ +import { Meta, ArgsTable, Canvas, Story, Description } from '@storybook/addon-docs/blocks'; + +import { WithGridLines, InContainer } from '../../helpers/StoryDecorators'; + +import * as stories from './VirtualCarousel.stories'; + +import { VirtualCarouselGridWrapper, VirtualCarousel, VirtualCarouselItem, VirtualCarouselCol } from '.'; + + + +# VirtualCarousel +Набор компонентов для создания списков с прокруткой с виртуализацией. + +## VirtualCarousel + + + +## VirtualCarouselGridWrapper + + +## VirtualCarouselItem + + + +## VirtualCarouselCol + + diff --git a/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.tsx b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.tsx new file mode 100644 index 000000000..6c9d4da94 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.tsx @@ -0,0 +1,306 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react'; +import type { SnapType, SnapAlign } from '@sberdevices/plasma-core'; + +import { isSberBox } from '../../utils'; +import { ProductCard, MusicCard, GalleryCard } from '../Card/Card.examples'; +import { DeviceThemeProvider } from '../Device'; +import { Row } from '../Grid'; +import { Body3 } from '../Typography/Body'; + +import { useRemoteHandlers } from './VirtualCarousel.hooks'; + +import { + VirtualCarouselGridWrapper, + VirtualCarousel, + VirtualCarouselItem, + VirtualCarouselCol, + VirtualCarouselProps, + VirtualCarouselCarouselColProps, +} from '.'; + +const items = Array(100) + .fill({ + title: 'Заголовок', + subtitle: 'Описание уравнение времени, сублимиpуя с повеpхности ядpа кометы, вращает реликтовый ледник', + imageSrc: `${process.env.PUBLIC_URL}/images/320_320_n.jpg`, + }) + .map(({ title, subtitle, imageSrc }, i) => ({ + title: `${title} ${i}`, + subtitle: `${subtitle} ${i}`, + imageSrc: imageSrc.replace('n', i % 12), + })); + +const snapTypes = ['mandatory', 'proximity'] as SnapType[]; +const snapAlign = ['start', 'center', 'end'] as SnapAlign[]; + +const isSberbox = isSberBox(); + +export default { + title: 'Controls/VirtualCarousel2', +} as Meta; + +export const Basic: Story = ({ + scrollSnapType, + scrollSnapAlign, +}) => { + const axis = 'x'; + const delayFrames = isSberbox ? 18 : 2; + const longDelayFrames = isSberbox ? 94 : 10; + const [index] = useRemoteHandlers({ + initialIndex: 0, + axis, + delayFrames, + longDelayFrames, + min: 0, + max: items.length - 1, + }); + + return ( + + + 800} + overscan={6} + carouselHeight={165} + renderItems={(visibleItems, currentIndex) => + visibleItems.map(({ index, start }) => { + const item = items[index]; + const { title, subtitle } = item; + return ( + + + + ); + }) + } + /> + + + ); +}; + +Basic.args = { + displayGrid: true, +}; + +Basic.argTypes = {}; + +// это пока можно не смотреть + +export const Vertical: Story = ({ + scrollAlign, + scrollSnapType, + scrollSnapAlign, + detectActive, + detectThreshold, +}) => { + const axis = 'y'; + + return ( + + 374} + overscan={6} + carouselHeight={700} + renderItems={(visibleItems, currentIndex) => + visibleItems.map(({ index, start }) => { + const item = items[index]; + const { title, subtitle } = item; + return ( + + + + ); + }) + } + /> + + ); +}; + +Vertical.args = { + ...Basic.args, +}; + +Vertical.argTypes = { + ...Basic.argTypes, +}; + +interface MusicPageProps { + displayGrid: boolean; + scrollSnapType: SnapType; + scrollSnapAlign: SnapAlign; +} + +export const MusicPage: Story = ({ scrollSnapType, scrollSnapAlign }) => { + return ( + +
+ Новые альбомы + + 327} + overscan={6} + carouselHeight={365} + renderItems={(visibleItems) => { + return visibleItems.map(({ index, start }) => { + const item = items[index]; + return ( + + + + ); + }); + }} + /> + +
+
+ Хиты и чарты + + 650} + overscan={6} + carouselHeight={412} + renderItems={(visibleItems) => { + return visibleItems.map(({ index, start }) => { + const item = items[index]; + return ( + + + + ); + }); + }} + /> + +
+
+ Жанры и настроения + + 490} + overscan={6} + carouselHeight={320} + renderItems={(visibleItems) => { + return visibleItems.map(({ index, start }) => { + const item = items[index]; + return ( + + + + ); + }); + }} + /> + +
+
+ ); +}; + +MusicPage.args = { + displayGrid: true, + scrollSnapType: 'mandatory', + scrollSnapAlign: 'start', +}; + +MusicPage.argTypes = { + ...Basic.argTypes, +}; diff --git a/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.tsx b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.tsx new file mode 100644 index 000000000..fd99ac330 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.tsx @@ -0,0 +1,110 @@ +import React, { RefObject, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { + VirtualCarouselContext, + VirtualCarousel as BaseCarousel, + VirtualCarouselTrack as BaseTrack, + VirtualCarouselProps as BaseProps, + applyNoSelect, +} from '@sberdevices/plasma-core'; +import { useVirtualForPlasma } from '@sberdevices/use-virtual'; + +import { useForkRef } from '../../hooks'; + +export type VirtualCarouselProps = BaseProps & { + /** + * Сменить WAI-ARIA Role списка. + */ + listRole?: string; + /** + * Сменить WAI-ARIA Label списка. + */ + listAriaLabel?: string; + index?: number; +}; + +const StyledVirtualCarousel = styled(BaseCarousel)``; +const StyledVirtualCarouselTrack = styled(BaseTrack)` + ${applyNoSelect}; +`; + +/** + * Компонент для создания списков с прокруткой. + */ +// eslint-disable-next-line prefer-arrow-callback +export const VirtualCarousel = React.forwardRef(function VirtualCarousel( + { + axis = 'x', + scrollSnapType = 'mandatory', + onScroll, + onIndexChange, + paddingStart, + paddingEnd, + listRole, + listAriaLabel, + itemCount, + estimateSize, + overscan, + renderItems, + carouselHeight, + index, + ...rest + }, + ref, +) { + const scrollRef = useRef(null); + const handleRef = useForkRef(scrollRef as RefObject, ref); + const { visibleItems, totalSize, currentIndex, scrollToIndex } = useVirtualForPlasma({ + itemCount, + parentRef: scrollRef as RefObject, + horizontal: axis === 'x', + paddingStart: (paddingStart ? parseFloat(paddingStart) : paddingStart) as number | undefined, + paddingEnd: (paddingEnd ? parseFloat(paddingEnd) : paddingEnd) as number | undefined, + estimateSize, + overscan, + scrollToFn: React.useCallback( + (offset: number) => { + scrollRef.current!.scrollTo({ [axis === 'y' ? 'top' : 'left']: offset, behavior: 'smooth' }); + }, + [axis], + ), + }); + + useEffect(() => { + if (typeof index === 'number') { + console.log('>>> index in useEffect:', index); + scrollToIndex(index); + } + }, [index, scrollToIndex]); + + useEffect(() => { + if (typeof index !== 'number' || index !== currentIndex) { + onIndexChange?.(currentIndex); + } + }, [onIndexChange, currentIndex]); + + return ( + + + + {renderItems(visibleItems, currentIndex)} + + + + ); +}); diff --git a/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarouselCol.tsx b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarouselCol.tsx new file mode 100644 index 000000000..9b202fe0e --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarouselCol.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import styled from 'styled-components'; +import { applyScrollSnap, ScrollSnapProps, VirtualCarouselItemProps } from '@sberdevices/plasma-core'; + +import { Col, ColProps } from '../Grid'; + +const StyledCol = styled(Col)` + ${applyScrollSnap}; +`; + +export interface VirtualCarouselCarouselColProps + extends ColProps, + VirtualCarouselItemProps, + React.HTMLAttributes { + axis: string; +} + +/** + * Элемент списка. В рамках интерфейса элемент наследуется от ``Col`` и ``CarouselItem``. + * Используется для каруселей с сеткой. + */ +export const VirtualCarouselCol: React.FC = ({ + axis, + start, + children, + style, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/plasma-ui/src/components/VirtualCarousel/index.ts b/packages/plasma-ui/src/components/VirtualCarousel/index.ts new file mode 100644 index 000000000..a3bacc8a6 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/index.ts @@ -0,0 +1,13 @@ +export { VirtualCarouselGridWrapper } from '@sberdevices/plasma-core'; + +export type { VirtualCarouselProps } from '@sberdevices/plasma-core'; + +export { VirtualCarouselItem } from '@sberdevices/plasma-core'; +export type { VirtualCarouselItemProps } from '@sberdevices/plasma-core'; + +export { useRemoteHandlers } from './VirtualCarousel.hooks'; + +export { VirtualCarousel } from './VirtualCarousel'; + +export { VirtualCarouselCol } from './VirtualCarouselCol'; +export type { VirtualCarouselCarouselColProps } from './VirtualCarouselCol'; diff --git a/packages/plasma-ui/src/components/VirtualCarousel/utils.ts b/packages/plasma-ui/src/components/VirtualCarousel/utils.ts new file mode 100644 index 000000000..4ba549839 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/utils.ts @@ -0,0 +1,59 @@ +export function debounceByFrames void>(fn: FN, framesToDebounce = 0) { + if (framesToDebounce === 0) { + return fn; + } + + let timeoutId: number | null = null; + let framesCounter = 0; + + return (...args: Parameters) => { + const tick = () => { + if (framesCounter === framesToDebounce - 1) { + timeoutId = null; + framesCounter = 0; + fn(...args); + } else { + framesCounter++; + timeoutId = requestAnimationFrame(tick); + } + }; + + if (timeoutId !== null) { + framesCounter = 0; + cancelAnimationFrame(timeoutId); + } + + timeoutId = requestAnimationFrame(tick); + }; +} + +// eslint-disable-next-line space-before-function-paren +export function throttleByFrames void>(fn: FN, framesToThrottle = 0) { + if (framesToThrottle === 0) { + return fn; + } + + let isWaited = false; + let framesCounter = 0; + + const tick = () => { + if (framesCounter === framesToThrottle - 1) { + isWaited = false; + framesCounter = 0; + } else { + framesCounter++; + requestAnimationFrame(tick); + } + }; + + return (...args: Parameters) => { + if (isWaited) { + return; + } + + fn(...args); + isWaited = true; + + tick(); + }; +} diff --git a/packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.examples.tsx b/packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.examples.tsx new file mode 100644 index 000000000..cd3b72594 --- /dev/null +++ b/packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.examples.tsx @@ -0,0 +1,55 @@ +import React, { FC } from 'react'; +import styled from 'styled-components'; + +import { addFocus } from '../../mixins'; +import { Image, ImageProps } from '../Image'; +import { Headline4, Footnote1 } from '../Typography'; + +interface VirtualCarouselCardProps extends React.AnchorHTMLAttributes { + title: string; + subtitle: string; + imageSrc: string; + imageAlt?: string; + imageBase?: ImageProps['base']; + style?: React.CSSProperties; +} + +const StyledCard = styled.a` + display: flex; + position: relative; + border-radius: 1rem; + + ${addFocus({ + outlined: true, + outlineRadius: '1.125rem', + })} +`; +const StyledCardContent = styled.div` + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 1.72rem; + color: #fff; +`; + +/** + * Карточка под примеры с каруселью. + * @private + */ +export const VirtualCarouselCard: FC = ({ + title, + subtitle, + imageSrc, + imageAlt, + imageBase = 'div', + ...rest +}) => ( + + {imageAlt} + + {title} + {subtitle} + + +); diff --git a/packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.stories.tsx b/packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.stories.tsx new file mode 100644 index 000000000..429a277e3 --- /dev/null +++ b/packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.stories.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Meta } from '@storybook/react'; +import { IconChevronLeft, IconChevronRight } from '@sberdevices/plasma-icons'; + +import { InSpacingDecorator } from '../../helpers'; +import { Button } from '../Button'; +import { SmartPaginationDots } from '../PaginationDots'; + +import { VirtualCarouselCard } from './VirtualCarousel.examples'; + +import { VirtualCarousel, VirtualCarouselItem } from '.'; + +export default { + title: 'Controls/VirtualCarousel', + component: VirtualCarousel, + decorators: [InSpacingDecorator], + argTypes: { + align: { + control: { + type: 'inline-radio', + options: ['center', 'start', 'end'], + }, + }, + }, +} as Meta; + +const items = Array(25) + .fill({ + title: 'Слайд', + subtitle: 'Описание слайда', + imageSrc: `${process.env.PUBLIC_URL}/images/320_320_n.jpg`, + imageAlt: 'Картинка', + }) + .map(({ title, subtitle, imageSrc, imageAlt }, i) => ({ + id: `slide_${i}`, + title: `${title} ${i}`, + subtitle: `${subtitle} ${i}`, + imageSrc: imageSrc.replace('n', i % 12), + imageAlt: `${imageAlt} ${i}`, + })); + +export const Default = () => { + return ( + 336 + 16} + renderItems={(visibleItems, currentIndex) => + visibleItems.map(({ index, start }) => { + const item = items[index]; + return ( + + + + ); + }) + } + /> + ); +}; +/* +const StyledWrapper = styled.div` + width: 32.5rem; + margin-left: auto; + margin-right: auto; +`; +const StyledCarouselWrapper = styled.section` + position: relative; + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; +`; +const StyledControls = styled.div` + position: absolute; + top: 1rem; + left: 1rem; + z-index: 1; +`; +const StyledCarousel = styled(VirtualCarousel)` + display: flex; + padding: 0.5rem 0; +`; +const StyledCarouselItem = styled(VirtualCarouselItem)` + width: 32.5rem; + padding: 0 0.5rem; + box-sizing: border-box; +`; + +export const AccessabilityDemo = () => { + const [index, setIndex] = useState(0); + const [ariaLive, setAriaLive] = useState<'off' | 'polite'>('off'); + + return ( + + setAriaLive('polite')} + onBlur={() => setAriaLive('off')} + onMouseOver={() => setAriaLive('polite')} + onMouseLeave={() => setAriaLive('off')} + > + +