diff --git a/packages/vkui/src/components/CustomScrollView/CustomScrollView.stories.tsx b/packages/vkui/src/components/CustomScrollView/CustomScrollView.stories.tsx index 7e60d2840e7..eda9d2218ed 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.stories.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, ReactRenderer, StoryObj } from '@storybook/react'; import type { PartialStoryFn } from 'storybook/internal/types'; import { CanvasFullLayout, DisableCartesianParam } from '../../storybook/constants'; import { createStoryParameters } from '../../testing/storybook/createStoryParameters'; -import { Div } from '../Div/Div'; +import { Box } from '../Box/Box'; import { CustomScrollView, type CustomScrollViewProps } from './CustomScrollView'; const Wrapper = (Story: PartialStoryFn) => ( @@ -36,7 +36,9 @@ export const Playground: Story = { args: { enableHorizontalScroll: true, children: ( -
+ ), }, decorators: [Wrapper], diff --git a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx index b7c6244bcf1..dd97ed124d4 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx @@ -1,8 +1,9 @@ -'use client'; - import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; +import { defineComponentDisplayNames } from '../../lib/react/defineComponentDisplayNames'; import type { HasRootRef } from '../../types'; +import { RootComponent } from '../RootComponent/RootComponent'; +import { CustomScrollViewTint } from './Tint/CustomScrollViewTint'; import styles from './CustomScrollView.module.css'; const overscrollBehaviorClassNames = { @@ -19,18 +20,6 @@ const scrollBehaviorClassNames = { export interface CustomScrollViewProps extends React.AllHTMLAttributes, HasRootRef { - /** - * `className` для компонента. - */ - className?: HTMLDivElement['className']; - /** - * Обработчик события `scroll`. - */ - onScroll?: (event: React.UIEvent) => void; - /** - * Содержимое. - */ - children: React.ReactNode; /** * Поведение overscroll, подробнее можно почитать в [документации](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior). */ @@ -55,31 +44,28 @@ export interface CustomScrollViewProps * @see https://vkui.io/components/custom-scroll-view */ export const CustomScrollView = ({ - className, - children, enableHorizontalScroll = false, - onScroll, - getRootRef, overscrollBehavior = 'auto', scrollBehavior = 'auto', scrollbarHidden = false, ...restProps }: CustomScrollViewProps): React.ReactNode => { return ( -
- {children} -
+ /> ); }; + +CustomScrollView.Tint = CustomScrollViewTint; + +if (process.env.NODE_ENV !== 'production') { + defineComponentDisplayNames(CustomScrollView.Tint, 'CustomScrollView.Tint'); +} diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css new file mode 100644 index 00000000000..ed43ff00072 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css @@ -0,0 +1,5 @@ +.host { + inline-size: 100%; + block-size: 100%; + mask-composite: intersect; +} diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx new file mode 100644 index 00000000000..ca3352067a4 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, ReactRenderer, StoryObj } from '@storybook/react'; +import type { PartialStoryFn } from 'storybook/internal/types'; +import { CanvasFullLayout, DisableCartesianParam } from '../../../storybook/constants'; +import { createStoryParameters } from '../../../testing/storybook/createStoryParameters'; +import { Box } from '../../Box/Box'; +import { CustomScrollView } from '../CustomScrollView'; +import { CustomScrollViewTint, type CustomScrollViewTintProps } from './CustomScrollViewTint'; + +const Wrapper = (Story: PartialStoryFn) => ( +
+ +
+); + +const story: Meta = { + title: 'Utils/CustomScrollView/Tint', + component: CustomScrollViewTint, + parameters: createStoryParameters( + 'CustomScrollViewTint', + CanvasFullLayout, + DisableCartesianParam, + ), + tags: ['Утилиты'], +}; + +export default story; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + children: (props) => ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a sollicitudin lectus, a + commodo sapien. Vivamus a urna leo. Integer iaculis dignissim urna, sit amet vestibulum + diam bibendum a. Donec eu arcu ut augue porttitor faucibus. Vestibulum nec pretium tortor, + sit amet congue nunc. Aenean ullamcorper ex sem, sed interdum quam consequat et. + Vestibulum a ex non diam fringilla feugiat. Nunc eu tellus sed leo elementum cursus. + Mauris blandit porta egestas. Curabitur eget justo elementum, malesuada lacus ut, congue + mauris. Integer orci nisi, convallis vitae dapibus sit amet, molestie a risus. Aenean + ultricies lacus eros, sit amet condimentum urna malesuada et. Sed quis dolor tempus orci + fringilla volutpat in sed velit. Aenean aliquet bibendum pretium. +
+
+ Cras pulvinar lobortis purus. Donec placerat suscipit leo vitae sodales. Phasellus + convallis lorem vitae arcu finibus pellentesque. In imperdiet vel leo a euismod. Nam sed + odio a neque venenatis suscipit a placerat magna. Mauris magna nisl, consequat nec augue + vitae, ultricies scelerisque ante. Phasellus pharetra risus eget imperdiet sodales. + Integer dignissim auctor semper. Nulla odio odio, euismod ut interdum in, bibendum sed + massa. Proin rutrum molestie massa in ultrices. Donec eu euismod turpis, eget lobortis + lorem. Nulla facilisi. Nam lacinia posuere turpis, sed laoreet turpis auctor nec. +
+
+ Curabitur eu fermentum mauris. Phasellus malesuada consectetur mollis. Pellentesque + pulvinar mauris turpis. Integer neque dolor, semper quis neque et, gravida commodo eros. + Duis efficitur ex a turpis blandit tincidunt. Mauris sem mi, imperdiet quis ligula sit + amet, fermentum vulputate felis. Phasellus eu ullamcorper dolor, porttitor pulvinar diam. + Aliquam euismod, mauris nec varius lacinia, ligula libero vulputate leo, ut tristique + massa nisi vitae tortor. Phasellus purus elit, gravida sit amet neque id, aliquam rutrum + dui. Maecenas luctus sem vitae molestie porttitor. Cras viverra mauris risus, at + sollicitudin ipsum interdum eu. Sed sit amet tempor enim. +
+
+ In hac habitasse platea dictumst. Etiam luctus erat metus, quis efficitur quam vulputate + quis. Duis ultricies non mauris condimentum molestie. Maecenas sollicitudin ex sem, quis + ultrices libero blandit eu. Vivamus in turpis pulvinar, malesuada enim at, hendrerit + magna. Proin eu nulla eget arcu pretium pharetra. Sed ullamcorper pulvinar est eu dapibus. + Cras at varius justo. In ex odio, condimentum id pellentesque a, sodales ut diam. +
+
+ Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; + Nam aliquet tempor laoreet. Maecenas eu pulvinar diam. Pellentesque habitant morbi + tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas et elit eros. + Quisque ullamcorper sodales nisi, eleifend aliquet metus venenatis in. Aliquam ornare a + lacus in tincidunt. Cras vel tristique metus. Sed vitae nisl at nisl imperdiet + sollicitudin. Sed sit amet enim in lectus imperdiet interdum condimentum et diam. Proin + venenatis sit amet diam ac vulputate. Donec mauris orci, semper volutpat nunc ut, + efficitur condimentum dolor. Vivamus in quam eget quam lacinia pharetra. Phasellus ipsum + magna, aliquet id elit eget, cursus tincidunt ex. In rhoncus turpis turpis, et viverra ex + malesuada vel. Donec nisi tellus, mollis et posuere vel, dictum eget neque. +
+
+ ), + }, + decorators: [Wrapper], +}; diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx new file mode 100644 index 00000000000..82e814c2c97 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx @@ -0,0 +1,195 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { baselineComponent } from '../../../testing/utils'; +import { CustomScrollViewTint } from './CustomScrollViewTint'; + +const TintWithChildren = (props: React.ComponentProps) => ( + + {({ getRootRef, onScroll }) => ( +
+
content
+
+ )} +
+); + +describe(CustomScrollViewTint, () => { + baselineComponent(TintWithChildren); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders children with correct props', () => { + const mockChildren = vi.fn(() =>
content
); + render({mockChildren}); + + expect(mockChildren).toHaveBeenCalledWith({ + getRootRef: expect.objectContaining({ current: null }), + onScroll: expect.any(Function), + }); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('shows tint top when scrolled down', async () => { + const ScrollableDiv = ({ getRootRef, onScroll }: any) => ( +
+
long content
+
+ ); + + render( + + {ScrollableDiv} + , + ); + + const scrollable = screen.getByTestId('scrollable'); + + fireEvent.scroll(scrollable, { target: { scrollTop: 10 } }); + + await act(() => vi.advanceTimersByTime(50)); + + const host = screen.getByTestId('custom-scroll-view-tint'); + expect(host).toHaveStyle('mask-image: linear-gradient(180deg, transparent, black 40px)'); + }); + + it('shows tint bottom when content overflows', async () => { + const ScrollableDiv = ({ getRootRef, onScroll }: any) => ( +
+
long content
+
+ ); + + render( + + {ScrollableDiv} + , + ); + + const scrollable = screen.getByTestId('scrollable'); + + // Mock scroll properties + Object.defineProperty(scrollable, 'scrollHeight', { value: 200 }); + Object.defineProperty(scrollable, 'clientHeight', { value: 100 }); + Object.defineProperty(scrollable, 'scrollTop', { value: 0 }); + + // Trigger update + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTime(50)); + + const host = screen.getByTestId('custom-scroll-view-tint'); + expect(host).toHaveStyle('mask-image: linear-gradient(0deg, transparent, black 40px)'); + }); + + it('shows tint left when scrolled right', async () => { + const ScrollableDiv = ({ getRootRef, onScroll }: any) => ( +
+
wide content
+
+ ); + + render( + + {ScrollableDiv} + , + ); + + const scrollable = screen.getByTestId('scrollable'); + + fireEvent.scroll(scrollable, { target: { scrollLeft: 10 } }); + + await act(() => vi.advanceTimersByTime(50)); + + const host = screen.getByTestId('custom-scroll-view-tint'); + expect(host).toHaveStyle('mask-image: linear-gradient(90deg, transparent, black 40px)'); + }); + + it('shows tint right when content overflows horizontally', async () => { + const ScrollableDiv = ({ getRootRef, onScroll }: any) => ( +
+
wide content
+
+ ); + + render( + + {ScrollableDiv} + , + ); + + const scrollable = screen.getByTestId('scrollable'); + + // Mock scroll properties + Object.defineProperty(scrollable, 'scrollWidth', { value: 200 }); + Object.defineProperty(scrollable, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollable, 'scrollLeft', { value: 0 }); + + // Trigger update + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTime(50)); + + const host = screen.getByTestId('custom-scroll-view-tint'); + expect(host).toHaveStyle('mask-image: linear-gradient(270deg, transparent, black 40px)'); + }); + + it('does not show tints when no scroll', async () => { + const ScrollableDiv = ({ getRootRef, onScroll }: any) => ( +
+
short content
+
+ ); + + render( + + {ScrollableDiv} + , + ); + + const scrollable = screen.getByTestId('scrollable'); + + // Mock no overflow + Object.defineProperty(scrollable, 'scrollHeight', { value: 50 }); + Object.defineProperty(scrollable, 'clientHeight', { value: 100 }); + Object.defineProperty(scrollable, 'scrollTop', { value: 0 }); + Object.defineProperty(scrollable, 'scrollWidth', { value: 100 }); + Object.defineProperty(scrollable, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollable, 'scrollLeft', { value: 0 }); + + await act(() => vi.advanceTimersByTime(50)); + + const host = screen.getByTestId('custom-scroll-view-tint'); + expect(host).toHaveStyle('mask-image: none'); + }); +}); diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx new file mode 100644 index 00000000000..3e21df3937e --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -0,0 +1,84 @@ +'use client'; + +import * as React from 'react'; +import { throttle } from '@vkontakte/vkjs'; +import { useResizeObserver } from '../../../hooks/useResizeObserver'; +import { type HasRootRef } from '../../../types'; +import { RootComponent } from '../../RootComponent/RootComponent'; +import styles from './CustomScrollViewTint.module.css'; + +export interface CustomScrollViewTintProps + extends Omit, 'children'>, + HasRootRef { + /** + * Компонент-обертка для реализации прокрутки с тенями. + */ + children: ( + props: Pick, 'onScroll'> & HasRootRef, + ) => React.ReactNode; +} + +function linearGradient(direction: 0 | 90 | 180 | 270): string { + return `linear-gradient(${direction}deg, transparent, black 40px)`; +} + +/** + * @see https://vkui.io/components/custom-scroll-view + * @since 8.1.0 + */ +export function CustomScrollViewTint({ + children, + ...restProps +}: CustomScrollViewTintProps): React.ReactElement { + const scrollRef = React.useRef(null); + + const [hasTintTop, setHasTintTop] = React.useState(false); + const [hasTintBottom, setHasTintBottom] = React.useState(false); + const [hasTintLeft, setHasTintLeft] = React.useState(false); + const [hasTintRight, setHasTintRight] = React.useState(false); + + const tint = [ + hasTintTop && linearGradient(180), + hasTintBottom && linearGradient(0), + hasTintLeft && linearGradient(90), + hasTintRight && linearGradient(270), + ].filter(Boolean) as string[]; + + const updateTint = React.useCallback((scrollElement: HTMLElement) => { + setHasTintTop(scrollElement.scrollTop > 1); + setHasTintBottom( + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1, + ); + setHasTintLeft(scrollElement.scrollLeft > 1); + setHasTintRight( + scrollElement.scrollWidth - scrollElement.clientWidth - scrollElement.scrollLeft > 1, + ); + }, []); + + const updateTintThrottle = React.useMemo(() => throttle(updateTint, 50), [updateTint]); + + React.useEffect(() => { + if (!scrollRef.current) { + return; + } + + updateTint(scrollRef.current); + }, [updateTint]); + + const onScroll = (e: React.UIEvent) => { + const target = e.currentTarget; + updateTintThrottle(target); + }; + + useResizeObserver(scrollRef, updateTintThrottle as (element: HTMLElement | Window) => void); + + return ( + + {children({ getRootRef: scrollRef, onScroll })} + + ); +} diff --git a/website/content/components/custom-scroll-view.mdx b/website/content/components/custom-scroll-view.mdx index 68c2f8bcd6a..9c82746467a 100644 --- a/website/content/components/custom-scroll-view.mdx +++ b/website/content/components/custom-scroll-view.mdx @@ -17,7 +17,7 @@ description: Компонент, который унифицирует брау ```jsx - {['Say', 'Hello', 'To', 'My', 'Little', 'Friend'].map((item) => ( + {["Say", "Hello", "To", "My", "Little", "Friend"].map((item) => ( }> {item} @@ -72,6 +72,26 @@ description: Компонент, который унифицирует брау Компонент позволяет обрабатывать событие `scroll`, возникающее при прокрутке контента, через свойство `onScroll`. +## CustomScrollView.Tint + +Подкомпонент, который добавляет тени к краям прокручиваемой области. + + + ```jsx + + {(props) => ( + + {["Say", "Hello", "To", "My", "Little", "Friend"].map((item) => ( + }> + {item} + + ))} + + )} + + ``` + + ## Свойства и методы [#api]