From 9c8f043f283f9b00cb44c104bb71a96a10e3e5d9 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Wed, 6 May 2026 08:48:55 +0000 Subject: [PATCH 1/3] feat: add CustomScrollView.Tint --- .../CustomScrollView/CustomScrollView.tsx | 36 ++-- .../Tint/CustomScrollViewTint.module.css | 5 + .../Tint/CustomScrollViewTint.stories.tsx | 101 +++++++++ .../Tint/CustomScrollViewTint.test.tsx | 195 ++++++++++++++++++ .../Tint/CustomScrollViewTint.tsx | 82 ++++++++ .../vkui/src/hooks/useResizeObserver.test.tsx | 92 ++++++++- packages/vkui/src/hooks/useResizeObserver.ts | 48 ++++- .../content/components/custom-scroll-view.mdx | 20 ++ 8 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css create mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx create mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx create mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx diff --git a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx index d5e9ef07b14..91f601e77ac 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx @@ -2,7 +2,10 @@ import type * 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 +22,6 @@ const scrollBehaviorClassNames = { export interface CustomScrollViewProps extends React.AllHTMLAttributes, HasRootRef { - /** - * `className` для компонента. - */ - className?: HTMLDivElement['className'] | undefined; - /** - * Обработчик события `scroll`. - */ - onScroll?: ((event: React.UIEvent) => void) | undefined; - /** - * Содержимое. - */ - children: React.ReactNode; /** * Поведение overscroll, подробнее можно почитать в [документации](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior). */ @@ -55,31 +46,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..3d70063ed6e --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -0,0 +1,82 @@ +'use client'; + +import * as React from 'react'; +import { throttle } from '@vkontakte/vkjs'; +import { useResizeObserverElement } 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 [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: Element) => { + 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]); + + const scrollRef = useResizeObserverElement(updateTintThrottle); + + React.useEffect(() => { + if (!scrollRef.current) { + return; + } + + updateTint(scrollRef.current); + }, [scrollRef, updateTint]); + + const onScroll = (e: React.UIEvent) => { + const target = e.currentTarget; + updateTintThrottle(target); + }; + + return ( + + {children({ getRootRef: scrollRef, onScroll })} + + ); +} diff --git a/packages/vkui/src/hooks/useResizeObserver.test.tsx b/packages/vkui/src/hooks/useResizeObserver.test.tsx index eb92c601f01..0e602ea37ae 100644 --- a/packages/vkui/src/hooks/useResizeObserver.test.tsx +++ b/packages/vkui/src/hooks/useResizeObserver.test.tsx @@ -1,7 +1,11 @@ import { act, useRef } from 'react'; import { render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useResizeObserver } from './useResizeObserver'; +import { + useResizeObserver, + useResizeObserverElement, + useWindowResizeEventListener, +} from './useResizeObserver'; // Глобальный мок ResizeObserver для всех тестов const callbacks = new Set(); @@ -86,3 +90,89 @@ describe('useResizeObserver', () => { expect(callback).toHaveBeenCalledExactlyOnceWith(window); }); }); + +describe('useResizeObserverElement', () => { + const Fixture = ({ resizeCallback }: { resizeCallback: (element: HTMLDivElement) => void }) => { + const ref = useResizeObserverElement(resizeCallback); + return ( +
+
+
+ ); + }; + + beforeEach(() => { + callbacks.clear(); + }); + + it('should call callback when resize is triggered', async () => { + const callback = vi.fn(); + + render(); + + await act(async () => { + triggerResize(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + }); + + it('should not call callback when ref has no element', () => { + const callback = vi.fn(); + + const FixtureNoRef = ({ + resizeCallback, + }: { + resizeCallback: (element: HTMLDivElement) => void; + }) => { + useResizeObserverElement(resizeCallback); + return
; + }; + + render(); + + triggerResize(); + + expect(callback).not.toHaveBeenCalled(); + }); +}); + +describe('useWindowResizeEventListener', () => { + it('should call listener on window resize', () => { + const listener = vi.fn(); + + const Fixture = ({ onResize }: { onResize: (event: UIEvent) => void }) => { + useWindowResizeEventListener(onResize); + return null; + }; + + render(); + + const event = new Event('resize'); + act(() => { + window.dispatchEvent(event); + }); + + expect(listener).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ type: 'resize' })); + }); + + it('should not call listener after unmount', () => { + const listener = vi.fn(); + + const Fixture = ({ onResize }: { onResize: (event: UIEvent) => void }) => { + useWindowResizeEventListener(onResize); + return null; + }; + + const { unmount } = render(); + + unmount(); + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vkui/src/hooks/useResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver.ts index c030ad0a750..e9e3d9d32ee 100644 --- a/packages/vkui/src/hooks/useResizeObserver.ts +++ b/packages/vkui/src/hooks/useResizeObserver.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import type * as React from 'react'; -import { isWindow } from '../lib/dom'; +import * as React from 'react'; +import { isWindow, useDOM } from '../lib/dom'; import { isRefObject } from '../lib/isRefObject'; import { useStableCallback } from './useStableCallback'; @@ -42,3 +42,47 @@ export function useResizeObserver( [ref, stableCallback], ); } + +export function useResizeObserverElement( + callback: (element: T) => void, +): React.RefObject { + const ref = React.useRef(null); + const stableCallback = useStableCallback(callback); + + useEffect( + function addResizeObserverHandler() { + if (!ref?.current) { + return; + } + + const element = ref.current; + + const observeFn = () => stableCallback(element); + + const observer: ResizeObserver = new ResizeObserver(observeFn); + observer.observe(element); + + return () => observer.disconnect(); + }, + [stableCallback], + ); + + return ref; +} + +export function useWindowResizeEventListener(listener: (event: UIEvent) => void) { + const { window } = useDOM(); + const stableListener = useStableCallback(listener); + + useEffect( + function addResizeObserverHandler() { + if (!window) { + return; + } + + window.addEventListener('resize', stableListener); + return () => window.removeEventListener('resize', stableListener); + }, + [stableListener, window], + ); +} diff --git a/website/content/components/custom-scroll-view.mdx b/website/content/components/custom-scroll-view.mdx index 1d5d24fed0c..00e60df94ea 100644 --- a/website/content/components/custom-scroll-view.mdx +++ b/website/content/components/custom-scroll-view.mdx @@ -73,6 +73,26 @@ tags: layout Компонент позволяет обрабатывать событие `scroll`, возникающее при прокрутке контента, через свойство `onScroll`. +## CustomScrollView.Tint + +Подкомпонент, который добавляет тени к краям прокручиваемой области. + + + ```jsx + + {(props) => ( + + {["Say", "Hello", "To", "My", "Little", "Friend"].map((item) => ( + }> + {item} + + ))} + + )} + + ``` + + ## Свойства и методы [#api] From e87ddfe8c15ee523744d10ea0bf974876dc619c3 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Mon, 10 Aug 2026 16:27:37 +0300 Subject: [PATCH 2/3] feat: add useCustomScrollTint --- .../CustomScrollView/CustomScrollView.tsx | 8 - .../Tint/CustomScrollViewTint.module.css | 5 - .../Tint/CustomScrollViewTint.stories.tsx | 101 ------ .../Tint/CustomScrollViewTint.test.tsx | 195 ----------- .../Tint/useCustomScrollViewTint.stories.tsx | 102 ++++++ .../Tint/useCustomScrollViewTint.test.tsx | 303 ++++++++++++++++++ ...ewTint.tsx => useCustomScrollViewTint.tsx} | 50 ++- packages/vkui/src/index.ts | 1 + website/components/mdx/Playground/scope.ts | 2 + .../content/components/custom-scroll-view.mdx | 35 +- 10 files changed, 450 insertions(+), 352 deletions(-) delete mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css delete mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx delete mode 100644 packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx create mode 100644 packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.stories.tsx create mode 100644 packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.test.tsx rename packages/vkui/src/components/CustomScrollView/Tint/{CustomScrollViewTint.tsx => useCustomScrollViewTint.tsx} (60%) diff --git a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx index 91f601e77ac..86b7ad482fd 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx @@ -2,10 +2,8 @@ import type * 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 = { @@ -65,9 +63,3 @@ export const CustomScrollView = ({ /> ); }; - -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 deleted file mode 100644 index ed43ff00072..00000000000 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.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 deleted file mode 100644 index ca3352067a4..00000000000 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 82e814c2c97..00000000000 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -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/useCustomScrollViewTint.stories.tsx b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.stories.tsx new file mode 100644 index 00000000000..65b4af72cc4 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryFn } from '@storybook/react'; +import { CanvasFullLayout, DisableCartesianParam } from '../../../storybook/constants'; +import { createStoryParameters } from '../../../testing/storybook/createStoryParameters'; +import { Box } from '../../Box/Box'; +import { CustomScrollView } from '../CustomScrollView'; +import { useCustomScrollViewTint } from './useCustomScrollViewTint'; + +const story: Meta = { + title: 'Utils/CustomScrollView/useCustomScrollViewTint', + parameters: createStoryParameters( + 'CustomScrollViewTint', + CanvasFullLayout, + DisableCartesianParam, + ), + tags: ['Утилиты'], + decorators: [ + (Component) => ( +
+ +
+ ), + ], +}; + +export default story; + +export const Playground: StoryFn = () => { + const { getRootRef, onScroll, style } = useCustomScrollViewTint(); + + return ( + + + 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 lobortus 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. +
+
+ ); +}; diff --git a/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.test.tsx b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.test.tsx new file mode 100644 index 00000000000..8b2d3ea60aa --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.test.tsx @@ -0,0 +1,303 @@ +import { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeTimersForScope } from '../../../testing/utils'; +import { useCustomScrollViewTint } from './useCustomScrollViewTint'; + +// Глобальный мок ResizeObserver для всех тестов +const callbacks = new Set(); + +class MockResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + callbacks.add(callback); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + observe() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + unobserve() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', MockResizeObserver); + +const triggerResize = () => { + callbacks.forEach((callback) => { + callback([], {} as unknown as ResizeObserver); + }); +}; + +/** + * Определяем scroll-свойства на элементе, так как jsdom не выполняет реальный layout + * и scrollHeight/scrollWidth всегда равны 0. + */ +const mockScrollMetrics = ( + element: HTMLElement, + metrics: { + scrollTop?: number; + scrollLeft?: number; + scrollHeight?: number; + clientHeight?: number; + scrollWidth?: number; + clientWidth?: number; + }, +) => { + const { + scrollTop = 0, + scrollLeft = 0, + scrollHeight = 0, + clientHeight = 0, + scrollWidth = 0, + clientWidth = 0, + } = metrics; + + Object.defineProperty(element, 'scrollTop', { + value: scrollTop, + configurable: true, + writable: true, + }); + Object.defineProperty(element, 'scrollLeft', { + value: scrollLeft, + configurable: true, + writable: true, + }); + Object.defineProperty(element, 'scrollHeight', { + value: scrollHeight, + configurable: true, + writable: true, + }); + Object.defineProperty(element, 'clientHeight', { + value: clientHeight, + configurable: true, + writable: true, + }); + Object.defineProperty(element, 'scrollWidth', { + value: scrollWidth, + configurable: true, + writable: true, + }); + Object.defineProperty(element, 'clientWidth', { + value: clientWidth, + configurable: true, + writable: true, + }); +}; + +const Fixture = ({ overflow = 'vertical' }: { overflow?: 'vertical' | 'horizontal' | 'both' }) => { + const { getRootRef, onScroll, style } = useCustomScrollViewTint(); + + return ( +
+
+ content +
+
+ ); +}; + +describe('useCustomScrollViewTint', () => { + fakeTimersForScope(); + + beforeEach(() => { + callbacks.clear(); + }); + + it('returns onScroll, getRootRef and style with default values', () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + expect(typeof scrollable.onscroll).toBe('object'); + expect(scrollable).toHaveStyle({ maskImage: 'none', maskComposite: 'intersect' }); + }); + + it('shows tint top when scrolled down', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + // Скроллим вниз, при этом контент не переполняет нижнюю границу + // (scrollHeight === clientHeight), чтобы проверить только верхнюю тень. + mockScrollMetrics(scrollable, { + scrollTop: 10, + scrollHeight: 100, + clientHeight: 100, + scrollWidth: 50, + clientWidth: 100, + }); + + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTimeAsync(50)); + + expect(scrollable).toHaveStyle({ + maskImage: 'linear-gradient(180deg, transparent, black 40px)', + maskComposite: 'intersect', + }); + }); + + it('shows tint bottom when content overflows vertically', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + mockScrollMetrics(scrollable, { + scrollTop: 0, + scrollHeight: 200, + clientHeight: 100, + scrollWidth: 50, + clientWidth: 100, + }); + + // updateTint вызывается на маунте, но throttled — прокручиваем для гарантированного обновления + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTimeAsync(50)); + + expect(scrollable).toHaveStyle({ + maskImage: 'linear-gradient(0deg, transparent, black 40px)', + maskComposite: 'intersect', + }); + }); + + it('shows tint left when scrolled right', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + // Скроллим вправо, при этом контент не переполняет правую границу + // (scrollWidth === clientWidth), чтобы проверить только левую тень. + mockScrollMetrics(scrollable, { + scrollLeft: 10, + scrollHeight: 50, + clientHeight: 100, + scrollWidth: 100, + clientWidth: 100, + }); + + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTimeAsync(50)); + + expect(scrollable).toHaveStyle({ + maskImage: 'linear-gradient(90deg, transparent, black 40px)', + maskComposite: 'intersect', + }); + }); + + it('shows tint right when content overflows horizontally', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + mockScrollMetrics(scrollable, { + scrollLeft: 0, + scrollHeight: 50, + clientHeight: 100, + scrollWidth: 200, + clientWidth: 100, + }); + + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTimeAsync(50)); + + expect(scrollable).toHaveStyle({ + maskImage: 'linear-gradient(270deg, transparent, black 40px)', + maskComposite: 'intersect', + }); + }); + + it('shows multiple tints when scrolled diagonally', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + mockScrollMetrics(scrollable, { + scrollTop: 10, + scrollLeft: 10, + scrollHeight: 200, + clientHeight: 100, + scrollWidth: 200, + clientWidth: 100, + }); + + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTimeAsync(50)); + + expect(scrollable).toHaveStyle({ + maskImage: + 'linear-gradient(180deg, transparent, black 40px), linear-gradient(0deg, transparent, black 40px), linear-gradient(90deg, transparent, black 40px), linear-gradient(270deg, transparent, black 40px)', + maskComposite: 'intersect', + }); + }); + + it('does not show tints when no overflow', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + mockScrollMetrics(scrollable, { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 50, + clientHeight: 100, + scrollWidth: 50, + clientWidth: 100, + }); + + fireEvent.scroll(scrollable); + + await act(() => vi.advanceTimersByTimeAsync(50)); + + expect(scrollable).toHaveStyle({ maskImage: 'none', maskComposite: 'intersect' }); + }); + + it('updates tint on resize', async () => { + render(); + + const scrollable = screen.getByTestId('scrollable'); + mockScrollMetrics(scrollable, { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 50, + clientHeight: 100, + scrollWidth: 50, + clientWidth: 100, + }); + + fireEvent.scroll(scrollable); + await act(() => vi.advanceTimersByTimeAsync(50)); + expect(scrollable).toHaveStyle({ maskImage: 'none', maskComposite: 'intersect' }); + + // Контент увеличился — должна появиться нижняя тень. + mockScrollMetrics(scrollable, { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 200, + clientHeight: 100, + scrollWidth: 50, + clientWidth: 100, + }); + + await act(async () => { + triggerResize(); + }); + + expect(scrollable).toHaveStyle({ + maskImage: 'linear-gradient(0deg, transparent, black 40px)', + maskComposite: 'intersect', + }); + }); +}); diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx similarity index 60% rename from packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx rename to packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx index 3d70063ed6e..bbe11998c2c 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx @@ -4,32 +4,23 @@ import * as React from 'react'; import { throttle } from '@vkontakte/vkjs'; import { useResizeObserverElement } 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)`; } +type UseCustomScrollViewTintResult = { + // eslint-disable-next-line jsdoc/require-jsdoc + onScroll: React.UIEventHandler | undefined; + // eslint-disable-next-line jsdoc/require-jsdoc + style: React.CSSProperties | undefined; +} & HasRootRef; + /** * @see https://vkui.io/components/custom-scroll-view * @since 8.1.0 */ -export function CustomScrollViewTint({ - children, - ...restProps -}: CustomScrollViewTintProps): React.ReactElement { +export function useCustomScrollViewTint(): UseCustomScrollViewTintResult { const [hasTintTop, setHasTintTop] = React.useState(false); const [hasTintBottom, setHasTintBottom] = React.useState(false); const [hasTintLeft, setHasTintLeft] = React.useState(false); @@ -55,28 +46,27 @@ export function CustomScrollViewTint({ const updateTintThrottle = React.useMemo(() => throttle(updateTint, 50), [updateTint]); - const scrollRef = useResizeObserverElement(updateTintThrottle); + const getRootRef = useResizeObserverElement(updateTintThrottle); React.useEffect(() => { - if (!scrollRef.current) { + if (!getRootRef.current) { return; } - updateTint(scrollRef.current); - }, [scrollRef, updateTint]); + updateTint(getRootRef.current); + }, [getRootRef, updateTint]); const onScroll = (e: React.UIEvent) => { const target = e.currentTarget; updateTintThrottle(target); }; - return ( - - {children({ getRootRef: scrollRef, onScroll })} - - ); + return { + onScroll, + getRootRef, + style: { + maskComposite: 'intersect', + maskImage: tint.join(', ') || 'none', + }, + }; } diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index 287ece88e3a..43b900d7d2c 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -519,6 +519,7 @@ export type { TransitionDirection } from './components/NavTransitionDirectionCon export { useCSSKeyframesAnimationController as unstable_useCSSKeyframesAnimationController } from './lib/animation'; export { useFocusVisible } from './hooks/useFocusVisible'; export { useReducedMotion } from './lib/animation'; +export { useCustomScrollViewTint } from './components/CustomScrollView/Tint/useCustomScrollViewTint'; /** * Utils diff --git a/website/components/mdx/Playground/scope.ts b/website/components/mdx/Playground/scope.ts index c9e90b873b4..3797a9b5a1f 100644 --- a/website/components/mdx/Playground/scope.ts +++ b/website/components/mdx/Playground/scope.ts @@ -143,6 +143,7 @@ import { UnstyledTextField, useAdaptivityConditionalRender, useAdaptivityWithJSMediaQueries, + useCustomScrollViewTint, useFocusVisible, useModalManager, usePagination, @@ -322,6 +323,7 @@ export const vkuiScope: Record = { useTodayDate, useTooltip, useReducedMotion, + useCustomScrollViewTint, }; export const scope: Record = { diff --git a/website/content/components/custom-scroll-view.mdx b/website/content/components/custom-scroll-view.mdx index 00e60df94ea..c40ab28ebfa 100644 --- a/website/content/components/custom-scroll-view.mdx +++ b/website/content/components/custom-scroll-view.mdx @@ -73,23 +73,32 @@ tags: layout Компонент позволяет обрабатывать событие `scroll`, возникающее при прокрутке контента, через свойство `onScroll`. -## CustomScrollView.Tint +## useCustomScrollViewTint -Подкомпонент, который добавляет тени к краям прокручиваемой области. +Хук, который добавляет тени к краям прокручиваемой области через CSS-маску (`maskImage`). +Маска реагирует на положение прокрутки: тень появляется только у тех краёв, за которыми +есть ещё контент. +Хук возвращает объект с тремя свойствами, которые нужно передать прокручиваемому элементу: + +- `getRootRef` — референс на прокручиваемый элемент; +- `onScroll` — обработчик события `scroll`; +- `style` — объект стилей с `maskImage` и `maskComposite` (необходимо применить к прокручиваемому элементу). + +{/* @example-description: Подсветка краёв прокручиваемой области через useCustomScrollViewTint. */} ```jsx - - {(props) => ( - - {["Say", "Hello", "To", "My", "Little", "Friend"].map((item) => ( - }> - {item} - - ))} - - )} - + const { getRootRef, onScroll, style } = useCustomScrollViewTint(); + + return ( + + {['Say', 'Hello', 'To', 'My', 'Little', 'Friend'].map((item) => ( + }> + {item} + + ))} + + ); ``` From 13452b82bf4fc33d23bdee475be287fa92225341 Mon Sep 17 00:00:00 2001 From: d-suvorov-vk-team Date: Wed, 12 Aug 2026 14:12:36 +0300 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: d-suvorov-vk-team --- .../Tint/useCustomScrollViewTint.tsx | 4 +- packages/vkui/vitest.setup.ts | 62 +++++++++++++++---- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx index bbe11998c2c..5dbd43edd8d 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx @@ -17,8 +17,8 @@ type UseCustomScrollViewTintResult = { } & HasRootRef; /** - * @see https://vkui.io/components/custom-scroll-view - * @since 8.1.0 + * @see https://vkui.io/components/custom-scroll-view/#usecustomscrollviewtint + * @since 8.4.0 */ export function useCustomScrollViewTint(): UseCustomScrollViewTintResult { const [hasTintTop, setHasTintTop] = React.useState(false); diff --git a/packages/vkui/vitest.setup.ts b/packages/vkui/vitest.setup.ts index 14a156c365b..d9c0dbc0c31 100644 --- a/packages/vkui/vitest.setup.ts +++ b/packages/vkui/vitest.setup.ts @@ -118,7 +118,7 @@ globalThis.getComputedStyle = function (el: Element, pseudoElt?: string | null) const computed = _origGetComputedStyle.call(globalThis, el); const inlineStyle = el instanceof HTMLElement ? el.style : null; - // У happy-dom есть два отличия от jsdom, влияющих на тесты: + // У happy-dom есть отличия от jsdom, влияющих на тесты: // // 1. `getComputedStyle` вычисляется только для элементов, подключённых к // `document` в момент вызова; для detached-элементов возвращается пустой @@ -131,13 +131,44 @@ globalThis.getComputedStyle = function (el: Element, pseudoElt?: string | null) // возвращал литерал неразрешённым — на это рассчитаны тесты, проверяющие, // что компонент прокинул `var(...)` в инлайн-стиль. // + // 3. happy-dom не регистрирует многие CSS-свойства (`mask-image`, + // `mask-composite`, `mask-clip`, …): их IDL-аксессоры (`el.style.maskImage`) + // хранят значение, но оно не попадает ни в CSS-storage + // (`el.style.getPropertyValue('mask-image')` → `''`), ни в + // `getComputedStyle`. В jsdom IDL-аксессоры синхронизированы со storage, + // поэтому `toHaveStyle({ maskImage: 'none' })` работал. Восстанавливаем + // это, читая значения IDL-аксессоров инлайн-стиля как fallback. + // // Проксируем результат, восстанавливая поведение jsdom: при чтении свойства // сначала отдаём подходящий инлайн-стиль, затем — значение из `computed`. + + // Читает инлайн-значение по имени свойства в любой нотации (camelCase или + // kebab-case). Сначала пробует CSS-storage (`getPropertyValue` для kebab), + // затем — IDL-аксессор (camelCase), который в happy-dom хранит + // незарегистрированные свойства вроде `maskImage`. + const readInline = (property: string): string => { + if (!inlineStyle) { + return ''; + } + const kebab = property.startsWith('--') + ? property + : property.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + const fromStorage = inlineStyle.getPropertyValue(kebab); + if (fromStorage) { + return fromStorage; + } + const camel = property.startsWith('--') + ? property + : kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + const fromIdl = (inlineStyle as unknown as Record)[camel]; + return typeof fromIdl === 'string' ? fromIdl : ''; + }; + return new Proxy(computed, { - get(target, prop, receiver) { + get(target, prop) { if (prop === 'getPropertyValue') { return (property: string) => { - const inline = inlineStyle?.getPropertyValue(property) ?? ''; + const inline = readInline(property); // Для custom property с `var()` отдаём сырой литерал, как jsdom. if (property.startsWith('--') && inline.includes('var(')) { return inline; @@ -146,20 +177,27 @@ globalThis.getComputedStyle = function (el: Element, pseudoElt?: string | null) if (!el.isConnected && inline) { return inline; } - return target.getPropertyValue(property); + const native = target.getPropertyValue(property); + // Восстанавливаем значения незарегистрированных в happy-dom свойств + // (см. п. 3 выше) для подключённых элементов. + return native || inline; }; } if (prop === 'length' || prop === 'item' || prop === Symbol.toPrimitive) { - return Reflect.get(target, prop, receiver); + // `Reflect.get` с `receiver` (= Proxy) ломает геттеры happy-dom, + // которые проверяют `this instanceof CSSStyleDeclaration` (например + // `length`). Передаём `target` — реальный экземпляр. + return Reflect.get(target, prop, target); } - // Прямой доступ к свойству (например `cs.transform`) для detached-элементов. - if (!el.isConnected) { - const inline = inlineStyle?.getPropertyValue(String(prop)) ?? ''; - if (inline) { - return inline; - } + // Прямой доступ к свойству (например `cs.transform` / `cs.maskImage`). + // Для detached-элементов инлайн — единственный источник; для подключённых + // он же служит fallback'ом по незарегистрированным свойствам (п. 3). + const inline = readInline(String(prop)); + if (!el.isConnected && inline) { + return inline; } - return Reflect.get(target, prop, receiver); + const native = Reflect.get(target, prop, target); + return native ?? (inline || undefined); }, }); };