From 185e7c1cf3f257535027c2d52b7b5c6c5387cd70 Mon Sep 17 00:00:00 2001 From: Dmitry Lekomtsev Date: Fri, 4 Mar 2022 01:24:02 +0300 Subject: [PATCH 1/5] feat(plasma-core): virtual carousel --- packages/plasma-core/package-lock.json | 16 +- packages/plasma-core/package.json | 20 +- .../VirtualCarousel/VirtualCarousel.tsx | 100 ++++++++ .../VirtualCarouselContext.tsx | 15 ++ .../VirtualCarousel/VirtualCarouselItem.tsx | 42 ++++ .../VirtualCarouselItemRefs.ts | 37 +++ .../src/components/VirtualCarousel/hooks.tsx | 221 ++++++++++++++++++ .../src/components/VirtualCarousel/index.ts | 13 ++ .../src/components/VirtualCarousel/types.tsx | 100 ++++++++ .../src/components/VirtualCarousel/utils.ts | 57 +++++ packages/plasma-core/src/index.ts | 1 + 11 files changed, 604 insertions(+), 18 deletions(-) create mode 100644 packages/plasma-core/src/components/VirtualCarousel/VirtualCarousel.tsx create mode 100644 packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselContext.tsx create mode 100644 packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItem.tsx create mode 100644 packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItemRefs.ts create mode 100644 packages/plasma-core/src/components/VirtualCarousel/hooks.tsx create mode 100644 packages/plasma-core/src/components/VirtualCarousel/index.ts create mode 100644 packages/plasma-core/src/components/VirtualCarousel/types.tsx create mode 100644 packages/plasma-core/src/components/VirtualCarousel/utils.ts 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..05af4dd3a --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselContext.tsx @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +import { VirtualCarouselItemRefs } from './VirtualCarouselItemRefs'; +import { ScrollAxis } from './types'; + +export interface VirtualCarouselState { + refs?: VirtualCarouselItemRefs; + 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..3a26087d2 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItem.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { applyScrollSnap, ScrollSnapProps } from '../../mixins'; +import type { AsProps } from '../../types'; + +import { useVirtualCarouselItem } from './hooks'; + +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 +}) => { + const ref = useVirtualCarouselItem(); + + return ( + + {children} + + ); +}; diff --git a/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItemRefs.ts b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItemRefs.ts new file mode 100644 index 000000000..dcc9dce5b --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/VirtualCarouselItemRefs.ts @@ -0,0 +1,37 @@ +import { MutableRefObject } from 'react'; + +/** + * Хранилище элементов карусели. + */ +export class VirtualCarouselItemRefs { + public items: MutableRefObject[] = []; + + private order() { + const children = this.items.find((item) => item.current?.parentNode?.children)?.current?.parentNode?.children; + + if (!children) { + return; + } + + const childrenArray = Array.from(children); + + this.items.sort((a, b) => { + if (a.current?.parentNode?.children && b.current?.parentNode?.children) { + return childrenArray.indexOf(a.current) - childrenArray.indexOf(b.current); + } + return 0; + }); + } + + public register(ref: MutableRefObject): number { + this.items.push(ref); + this.order(); + + return this.items.length - 1; + } + + public unregister(ref: React.MutableRefObject) { + this.items.splice(this.items.indexOf(ref), 1); + this.order(); + } +} diff --git a/packages/plasma-core/src/components/VirtualCarousel/hooks.tsx b/packages/plasma-core/src/components/VirtualCarousel/hooks.tsx new file mode 100644 index 000000000..fda326e7a --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/hooks.tsx @@ -0,0 +1,221 @@ +import { useContext, useRef, useMemo, useEffect, useCallback } from 'react'; +import throttle from 'lodash.throttle'; + +import { useDebouncedFunction } from '../../hooks'; + +import { VirtualCarouselContext } from './VirtualCarouselContext'; +import { VirtualCarouselItemRefs } from './VirtualCarouselItemRefs'; +import type { BasicProps, DetectionProps } from './types'; +import { getCalculatedOffset, getItemSlot } from './utils'; + +export const useVirtualCarouselContext = () => useContext(VirtualCarouselContext); + +/** + * Хук для передачи рефа айтема в контекст карусели. + */ +export function useVirtualCarouselItem() { + const innerRef = useRef(null); + const { refs } = useVirtualCarouselContext(); + + useEffect(() => { + refs?.register(innerRef); + return () => refs?.unregister(innerRef); + }, [refs]); + + return innerRef; +} + +const THROTTLE_DEFAULT_MS = 100; +const DEBOUNCE_DEFAULT_MS = 150; + +export const useVirtualCarousel = ({ + axis, + detectActive = false, + detectThreshold = 0.5, + scrollAlign = 'center', + scaleCallback, + scaleResetCallback, + onScroll, + onIndexChange, + throttleMs = THROTTLE_DEFAULT_MS, + debounceMs = DEBOUNCE_DEFAULT_MS, +}: Omit & + Omit, 'detectActive'> & { detectActive?: boolean }) => { + const prevIndex = useRef(null); + const direction = useRef(null); + const offset = useRef(0); + const refs = useMemo(() => new VirtualCarouselItemRefs(), []); + const scrollRef = useRef(null); + const trackRef = useRef(null); + + /** + * Для того, чтобы не спамить изменениями индекса. + * Задержка дебаунса слегка больше, чем у тротлинга. + * Таким образом, событие срабатывает при завершении скролла. + */ + const debouncedOnIndexChange = useDebouncedFunction((i: number) => onIndexChange?.(i), debounceMs); + + /** + * Вычисление центрального элемента. + * Подсчет: от 0 до 1, какое количество ширины/высоты + * каждого элемента находится по центру скролла. + */ + const detectActiveItem = useCallback( + throttle(() => { + if (!scrollRef.current || !trackRef.current || !detectActive) { + return; + } + + /** + * Правая (или нижняя для Оу) граница элемента. + */ + let itemEdge = offset.current; + + /** + * Смещение (отрицательный или положительный отступ) + * и размер карусели (для Ox - ширина, для Oy - высота). + */ + const scrollPos = scrollRef.current[axis === 'x' ? 'scrollLeft' : 'scrollTop']; + const scrollSize = scrollRef.current[axis === 'x' ? 'offsetWidth' : 'offsetHeight']; + + /** + * Граница скролла (видимой части). + * Смещение + размер. + */ + const scrollEdge = scrollPos + scrollSize; + + /** + * Элементы перед, после и в видимой части. + * перед [ ВИДИМЫЕ ] после + */ + const prevItems: HTMLElement[] = []; + const nextItems: HTMLElement[] = []; + let count = 0; + + /** + * Проходим по всему списку, суммируя ширины элементов, + * пока не найдем один элемент, чей центр будет в центре карусели. + */ + refs.items.forEach((itemRef, itemIndex) => { + if (!itemRef.current) { + return; + } + + /** + * Для Ox - ширина, для Oy - высота. + */ + const itemSize = itemRef.current[axis === 'x' ? 'offsetWidth' : 'offsetHeight']; + + /** + * Все элементы правее вьюпорта выпадают из процедуры. + * Сравниваем по предыдущему элементу. + * [ ... ] ...|n| <- Левый край элемента за пределами начала видимой части + */ + if (itemEdge > scrollEdge) { + if (scaleCallback && scaleResetCallback) { + nextItems.push(itemRef.current); + } + return; + } + + itemEdge += itemSize; + + /** + * Все элементы левее вьюпорта выпадают из процедуры. + * Сравниваем по текущему элементу. + * Правый край элемента за пределами начала видимой части -> |p|... [ ... ] + */ + if (scrollPos > itemEdge) { + if (scaleCallback && scaleResetCallback) { + prevItems.push(itemRef.current); + } + return; + } + + const itemSlot = getItemSlot( + itemIndex, + itemEdge, + itemSize, + scrollPos, + scrollSize, + scrollAlign, + prevIndex.current || 0, + offset.current, + ); + + if (itemSlot !== null) { + if (detectThreshold && Math.abs(itemSlot) <= detectThreshold) { + console.log('>>> here we are:', itemIndex); + debouncedOnIndexChange(itemIndex); + } + + if (scaleCallback) { + scaleCallback(itemRef.current, itemSlot); + /** + * Количество айтемов в видимой части. + */ + count++; + } + } + }); + + if (scaleCallback && scaleResetCallback) { + window.requestAnimationFrame(() => { + if (direction.current) { + if (nextItems.length) { + nextItems.splice(0, count).forEach((elem) => scaleCallback(elem, count)); + if (nextItems.length) { + nextItems.splice(0, count).forEach((elem) => scaleResetCallback(elem)); + } + } + } else if (prevItems.length) { + const prItemsRev = prevItems.reverse(); + prItemsRev.splice(0, count).forEach((elem) => scaleCallback(elem, count * -1)); + if (prItemsRev.length) { + prItemsRev.splice(0, count).forEach((elem) => scaleResetCallback(elem)); + } + } + }); + } + }, throttleMs), + [axis, scrollRef, onIndexChange], + ); + + /** + * Обработчик скролла на DOM-узел. + */ + const handleScroll = useCallback( + (event) => { + detectActiveItem(); + onScroll?.(event); + }, + [detectActiveItem, onScroll], + ); + + /** + * Операции на маунте/анмаунте компонента. + * Здесь нужно сделать кешируемые вычисления, + * Создать слушатели событи и т.п. + */ + useEffect(() => { + if (scrollRef.current && trackRef.current) { + offset.current = getCalculatedOffset(scrollRef.current, trackRef.current, axis); + + setTimeout(() => { + /** + * Если на момент запуска карусель уже находится на нужной позиции, + * событие скролла не произойдет, не сработает и определение центра, + * необходимо вызвать его вручную. + */ + detectActiveItem(); + }); + } + }, []); + + return { + scrollRef, + trackRef, + refs, + handleScroll, + }; +}; 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..9ff805ae6 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/index.ts @@ -0,0 +1,13 @@ +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 { useVirtualCarousel, useVirtualCarouselItem, useVirtualCarouselContext } from './hooks'; + +export type { VirtualCarouselProps } from './types'; +export { VirtualCarouselItemRefs } from './VirtualCarouselItemRefs'; 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..364dd6b31 --- /dev/null +++ b/packages/plasma-core/src/components/VirtualCarousel/types.tsx @@ -0,0 +1,100 @@ +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; + /** + * Центрирование активного элемента при скролле + */ + scrollAlign?: ScrollAlign; + /** + * Отступ в начале, используется при центрировании крайних элементов + */ + paddingStart?: string; + /** + * Отступ в конце, используется при центрировании крайних элементов + */ + paddingEnd?: string; + /** + * Throttling внутренних обработчиков события onScroll + */ + throttleMs?: number; + /** + * Debounce внутренних обработчиков события onScroll + */ + debounceMs?: number; + /** + * Обработчик события скролла + */ + 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 { + /** + * Вычислять активный элемент + */ + detectActive: true; + /** + * Пороговое значение определения центрального элемента (0-1) + */ + detectThreshold: number; + /** + * Коллбек изменения индекса + */ + onIndexChange?: (index: number) => void; + /** + * Обработчик стилизации элемента во вьюпорте + */ + scaleCallback?: (itemEl: HTMLElement, slot: number) => void; + /** + * Обработчик для сброса стилей элементов, находящихся вне вьюпорта + */ + scaleResetCallback?: (itemEl: HTMLElement) => 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'; From 6cace4e4b30c033f4ad12572dd95fe30068b1e82 Mon Sep 17 00:00:00 2001 From: Dmitry Lekomtsev Date: Fri, 4 Mar 2022 01:24:25 +0300 Subject: [PATCH 2/5] feat(plasma-ui): virtual carousel --- packages/plasma-ui/package-lock.json | 5 + packages/plasma-ui/package.json | 1 + .../VirtualCarousel.examples.tsx | 76 +++++ .../VirtualCarousel/VirtualCarousel.hooks.tsx | 110 ++++++ .../VirtualCarousel.stories.mdx | 27 ++ .../VirtualCarousel.stories.tsx | 321 ++++++++++++++++++ .../VirtualCarousel/VirtualCarousel.tsx | 116 +++++++ .../VirtualCarousel/VirtualCarouselCol.tsx | 51 +++ .../src/components/VirtualCarousel/index.ts | 13 + 9 files changed, 720 insertions(+) create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.examples.tsx create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.hooks.tsx create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.mdx create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.tsx create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.tsx create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/VirtualCarouselCol.tsx create mode 100644 packages/plasma-ui/src/components/VirtualCarousel/index.ts diff --git a/packages/plasma-ui/package-lock.json b/packages/plasma-ui/package-lock.json index 1f1937986..a4823b026 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.7.1", + "resolved": "https://registry.npmjs.org/@sberdevices/use-virtual/-/use-virtual-0.7.1.tgz", + "integrity": "sha512-j9n8qNi7J4l8YiNj6oH7Fo1f83PuCf+KpX5WfiCErjPD9dkx6QEuPNP+F3IDwKnzWUz+5/ojwnxeEOJ2LiX7wQ==" + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": false, diff --git a/packages/plasma-ui/package.json b/packages/plasma-ui/package.json index e61f21eaa..f686fe508 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.7.1", "color": "3.1.2", "lodash.throttle": "4.1.1", "react-draggable": "4.4.3" 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..3ca8eb982 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.hooks.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import throttle from 'lodash.throttle'; +import { ScrollAxis } from '@sberdevices/plasma-core'; + +import { useRemoteListener } from '../../hooks'; + +const throttlingParamsDefault = { + leading: true, + trailing: false, +}; + +export { useVirtualCarouselContext, useVirtualCarouselItem } from '@sberdevices/plasma-core'; + +/** + * Хук для навигации. Слушает нажатие кнопок на пульте/клавиатуре. + */ +export function useRemoteHandlers({ + initialIndex = 0, + axis, + delay, + longDelay, + min, + max, + count = 1, + longCount = 5, + throttlingParams = throttlingParamsDefault, +}: { + initialIndex: number; + axis: ScrollAxis; + delay: number; + longDelay: number; + min: number; + max: number; + count?: number; + longCount?: number; + throttlingParams?: typeof throttlingParamsDefault; +}) { + const indexState = React.useState(initialIndex); + const [, setIndex] = indexState; + + const step = React.useCallback( + throttle( + (cmd: '+' | '-') => + setIndex((prevIndex) => { + if (cmd === '+') { + return prevIndex + count <= max ? prevIndex + count : min; + } + return prevIndex - count >= min ? prevIndex - count : max; + }), + delay, + throttlingParams, + ), + [min, max], + ); + const jump = React.useCallback( + throttle( + (cmd: '+' | '-') => + setIndex((prevIndex) => { + if (cmd === '+') { + return prevIndex + longCount <= max ? prevIndex + longCount : min; + } + return prevIndex - longCount >= min ? prevIndex - longCount : max; + }), + longDelay, + throttlingParams, + ), + [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..5e5c53e47 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.stories.tsx @@ -0,0 +1,321 @@ +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 { + 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/VirtualCarousel', +} as Meta; + +export const Basic: Story = ({ + scrollAlign, + scrollSnapType, + scrollSnapAlign, + detectActive, + detectThreshold, +}) => { + const axis = 'x'; + + 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, + scrollAlign: 'start', + scrollSnapType: !isSberbox ? 'mandatory' : undefined, + scrollSnapAlign: !isSberbox ? 'start' : undefined, + detectActive: true, + detectThreshold: 0.5, +}; + +Basic.argTypes = { + scrollAlign: { + control: { + type: 'select', + options: ['center', 'start', 'end', 'activeDirection'], + }, + }, + scrollSnapType: { + control: { + type: 'inline-radio', + options: snapTypes, + }, + }, + scrollSnapAlign: { + control: { + type: 'inline-radio', + options: snapAlign, + }, + }, +}; + +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..6970012b9 --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarousel.tsx @@ -0,0 +1,116 @@ +import React, { RefObject } from 'react'; +import styled from 'styled-components'; +import { + useVirtualCarousel, + VirtualCarouselContext, + VirtualCarousel as BaseCarousel, + VirtualCarouselTrack as BaseTrack, + VirtualCarouselProps as BaseProps, + applyNoSelect, +} from '@sberdevices/plasma-core'; +import { useVirtual } from '@sberdevices/use-virtual'; + +import { useForkRef } from '../../hooks'; + +export type VirtualCarouselProps = BaseProps & { + /** + * Сменить WAI-ARIA Role списка. + */ + listRole?: string; + /** + * Сменить WAI-ARIA Label списка. + */ + listAriaLabel?: string; +}; + +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', + scrollAlign, + detectActive, + detectThreshold, + scaleCallback, + scaleResetCallback, + onScroll, + onIndexChange, + paddingStart, + paddingEnd, + throttleMs, + debounceMs, + listRole, + listAriaLabel, + itemCount, + estimateSize, + overscan, + renderItems, + carouselHeight, + ...rest + }, + ref, +) { + const { scrollRef, trackRef, refs, handleScroll } = useVirtualCarousel({ + axis, + scrollAlign, + detectActive, + detectThreshold, + scaleCallback, + scaleResetCallback, + onScroll, + onIndexChange, + throttleMs, + debounceMs, + }); + const handleRef = useForkRef(scrollRef as RefObject, ref); + const { visibleItems, totalSize, currentIndex } = useVirtual({ + 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) => { + console.log('scroll to fn'); + scrollRef.current!.scrollTo({ [axis === 'y' ? 'top' : 'left']: offset, behavior: 'smooth' }); + // animatedScrollToX(scrollRef.current as HTMLDivElement, offset); + }, + [axis], + ), + }); + return ( + + + } + axis={axis} + paddingStart={paddingStart} + paddingEnd={paddingEnd} + role={listRole} + aria-label={listAriaLabel} + style={{ [axis === 'x' ? 'width' : 'height']: `${totalSize}px` }} + carouselHeight={carouselHeight} + > + {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..25708ae8f --- /dev/null +++ b/packages/plasma-ui/src/components/VirtualCarousel/VirtualCarouselCol.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import styled from 'styled-components'; +import { applyScrollSnap, ScrollSnapProps, VirtualCarouselItemProps } from '@sberdevices/plasma-core'; + +import { Col, ColProps } from '../Grid'; + +import { useVirtualCarouselItem } from './VirtualCarousel.hooks'; + +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 +}) => { + const itemRef = useVirtualCarouselItem(); + + 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..e61fa6630 --- /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 { useVirtualCarouselItem, useVirtualCarouselContext, useRemoteHandlers } from './VirtualCarousel.hooks'; + +export { VirtualCarousel } from './VirtualCarousel'; + +export { VirtualCarouselCol } from './VirtualCarouselCol'; +export type { VirtualCarouselCarouselColProps } from './VirtualCarouselCol'; From 62b9dd4c9af971669911f5efa1ce77ea53bf13d4 Mon Sep 17 00:00:00 2001 From: Dmitry Lekomtsev Date: Wed, 9 Mar 2022 17:51:37 +0300 Subject: [PATCH 3/5] chore: wip --- .../VirtualCarousel.examples.tsx | 55 +++++++ .../VirtualCarousel.stories.tsx | 147 ++++++++++++++++++ .../VirtualCarousel/VirtualCarousel.tsx | 103 ++++++++++++ .../src/components/VirtualCarousel/index.ts | 5 + 4 files changed, 310 insertions(+) create mode 100644 packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.examples.tsx create mode 100644 packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.stories.tsx create mode 100644 packages/plasma-web/src/components/VirtualCarousel/VirtualCarousel.tsx create mode 100644 packages/plasma-web/src/components/VirtualCarousel/index.ts 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')} + > + +