From 886c55f474aab652c02964dca7a7b4fa37f23df1 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Fri, 16 Jan 2026 14:27:08 +0300 Subject: [PATCH 1/5] feat: add CustomScrollView.Tint --- .../CustomScrollView.stories.tsx | 8 +- .../CustomScrollView/CustomScrollView.tsx | 24 +-- .../Tint/CustomScrollViewTint.module.css | 64 ++++++ .../Tint/CustomScrollViewTint.stories.tsx | 102 +++++++++ .../Tint/CustomScrollViewTint.test.tsx | 197 ++++++++++++++++++ .../Tint/CustomScrollViewTint.tsx | 88 ++++++++ .../content/components/custom-scroll-view.mdx | 22 +- 7 files changed, 485 insertions(+), 20 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.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..964c552bcf5 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx @@ -1,8 +1,8 @@ -'use client'; - import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; +import { defineComponentDisplayNames } from '../../lib/react/defineComponentDisplayNames'; import type { HasRootRef } from '../../types'; +import { CustomScrollViewTint } from './Tint/CustomScrollViewTint'; import styles from './CustomScrollView.module.css'; const overscrollBehaviorClassNames = { @@ -19,18 +19,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). */ @@ -58,7 +46,6 @@ export const CustomScrollView = ({ className, children, enableHorizontalScroll = false, - onScroll, getRootRef, overscrollBehavior = 'auto', scrollBehavior = 'auto', @@ -76,10 +63,15 @@ export const CustomScrollView = ({ scrollbarHidden && styles.scrollbarHidden, )} ref={getRootRef} - onScroll={onScroll} {...restProps} > {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..9a668617949 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css @@ -0,0 +1,64 @@ +.host { + position: relative; + inline-size: 100%; + block-size: 100%; + + --vkui_internal--CustomScrollView_tint_color: var(--vkui--color_background_content); +} + +.tint { + position: absolute; +} + +/* stylelint-disable csstools/use-logical -- прокрутка рассчитывается по физическим величинам */ +.tintTop, +.tintBottom { + right: 0; + left: 0; + height: 40px; +} + +.tintTop { + top: 0; + background: linear-gradient( + to bottom, + var(--vkui_internal--CustomScrollView_tint_color), + transparent + ); +} + +.tintBottom { + bottom: 0; + background: linear-gradient( + to top, + var(--vkui_internal--CustomScrollView_tint_color), + transparent + ); +} + +.tintLeft, +.tintRight { + top: 0; + bottom: 0; + width: 40px; +} + +.tintLeft { + left: 0; + background: linear-gradient( + to right, + var(--vkui_internal--CustomScrollView_tint_color), + transparent + ); +} + +.tintRight { + right: 0; + background: linear-gradient( + to left, + var(--vkui_internal--CustomScrollView_tint_color), + transparent + ); +} + +/* stylelint-enable csstools/use-logical */ 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..ed0f12a0c16 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx @@ -0,0 +1,102 @@ +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: { + tintColor: 'var(--vkui--color_background)', + 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..c39275bb6ad --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.test.tsx @@ -0,0 +1,197 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { baselineComponent } from '../../../testing/utils'; +import { CustomScrollViewTint } from './CustomScrollViewTint'; +import styles from './CustomScrollViewTint.module.css'; + +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 tintTop = document.querySelector(`.${styles.tintTop}`); + expect(tintTop).toBeInTheDocument(); + }); + + 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 tintBottom = document.querySelector(`.${styles.tintBottom}`); + expect(tintBottom).toBeInTheDocument(); + }); + + 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 tintLeft = document.querySelector(`.${styles.tintLeft}`); + expect(tintLeft).toBeInTheDocument(); + }); + + 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 tintRight = document.querySelector(`.${styles.tintRight}`); + expect(tintRight).toBeInTheDocument(); + }); + + it('applies tintColor as CSS variable', () => { + render( + + {({ getRootRef, onScroll }) => ( +
+ content +
+ )} +
, + ); + const host = document.querySelector(`.${styles.host}`); + expect(host).toHaveStyle('--vkui_internal--CustomScrollView_tint_color: red'); + }); + + 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 tintTop = document.querySelector(`.${styles.tintTop}`); + const tintBottom = document.querySelector(`.${styles.tintBottom}`); + const tintLeft = document.querySelector(`.${styles.tintLeft}`); + const tintRight = document.querySelector(`.${styles.tintRight}`); + + expect(tintTop).not.toBeInTheDocument(); + expect(tintBottom).not.toBeInTheDocument(); + expect(tintLeft).not.toBeInTheDocument(); + expect(tintRight).not.toBeInTheDocument(); + }); +}); 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..4907cc21d81 --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -0,0 +1,88 @@ +'use client'; + +import * as React from 'react'; +import { classNames, throttle } from '@vkontakte/vkjs'; +import { mergeStyle } from '../../../helpers/mergeStyle'; +import { type HasRootRef } from '../../../types'; +import styles from './CustomScrollViewTint.module.css'; + +export interface CustomScrollViewTintProps + extends Omit, 'children'>, + HasRootRef { + /** + * Цвет тени. + */ + tintColor?: string; + /** + * Компонент-обертка для реализации прокрутки с тенями. + */ + children: ( + props: Pick, 'onScroll'> & HasRootRef, + ) => React.ReactNode; +} + +/** + * @see https://vkui.io/components/custom-scroll-view + * @since 8.1.0 + */ +export function CustomScrollViewTint({ + getRootRef, + className, + style, + children, + tintColor, + ...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 updateTint = React.useMemo( + () => + throttle((scrollElement: HTMLDivElement) => { + setHasTintTop(scrollElement.scrollTop > 0); + setHasTintBottom( + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 0, + ); + setHasTintLeft(scrollElement.scrollLeft > 0); + setHasTintRight( + scrollElement.scrollWidth - scrollElement.clientWidth - scrollElement.scrollLeft > 0, + ); + }, 50), + [], + ); + + React.useEffect(() => { + if (!scrollRef.current) { + return; + } + + updateTint(scrollRef.current); + }, [updateTint]); + + const onScroll = (e: React.UIEvent) => { + const target = e.currentTarget; + updateTint(target); + }; + + return ( +
+ {children({ getRootRef: scrollRef, onScroll })} + {hasTintTop &&
} + {hasTintBottom &&
} + {hasTintLeft &&
} + {hasTintRight &&
} +
+ ); +} 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] From 32fe7ecf85771ed2500f9109a8ab91ba123a8825 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Fri, 16 Jan 2026 16:03:28 +0300 Subject: [PATCH 2/5] fix: review --- .../CustomScrollView/CustomScrollView.tsx | 14 +++------- .../Tint/CustomScrollViewTint.tsx | 28 ++++++++----------- packages/vkui/src/hooks/useResizeObserver.ts | 10 ++++++- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx index 964c552bcf5..dd97ed124d4 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx @@ -2,6 +2,7 @@ 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'; @@ -43,30 +44,23 @@ export interface CustomScrollViewProps * @see https://vkui.io/components/custom-scroll-view */ export const CustomScrollView = ({ - className, - children, enableHorizontalScroll = false, - getRootRef, overscrollBehavior = 'auto', scrollBehavior = 'auto', scrollbarHidden = false, ...restProps }: CustomScrollViewProps): React.ReactNode => { return ( -
- {children} -
+ /> ); }; diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx index 4907cc21d81..add48d5688d 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -2,8 +2,9 @@ import * as React from 'react'; import { classNames, throttle } from '@vkontakte/vkjs'; -import { mergeStyle } from '../../../helpers/mergeStyle'; -import { type HasRootRef } from '../../../types'; +import { RootComponent } from 'src/components/RootComponent/RootComponent'; +import { useResizeObserver } from '../../../hooks/useResizeObserver'; +import { type CSSCustomProperties, type HasRootRef } from '../../../types'; import styles from './CustomScrollViewTint.module.css'; export interface CustomScrollViewTintProps @@ -26,9 +27,6 @@ export interface CustomScrollViewTintProps * @since 8.1.0 */ export function CustomScrollViewTint({ - getRootRef, - className, - style, children, tintColor, ...restProps @@ -42,7 +40,7 @@ export function CustomScrollViewTint({ const updateTint = React.useMemo( () => - throttle((scrollElement: HTMLDivElement) => { + throttle((scrollElement: HTMLElement) => { setHasTintTop(scrollElement.scrollTop > 0); setHasTintBottom( scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 0, @@ -68,21 +66,19 @@ export function CustomScrollViewTint({ updateTint(target); }; + const baseStyle: CSSCustomProperties | undefined = tintColor + ? { '--vkui_internal--CustomScrollView_tint_color': tintColor } + : undefined; + + useResizeObserver(scrollRef, updateTint); + return ( -
+ {children({ getRootRef: scrollRef, onScroll })} {hasTintTop &&
} {hasTintBottom &&
} {hasTintLeft &&
} {hasTintRight &&
} -
+ ); } diff --git a/packages/vkui/src/hooks/useResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver.ts index e8fc57a9362..632c7b66477 100644 --- a/packages/vkui/src/hooks/useResizeObserver.ts +++ b/packages/vkui/src/hooks/useResizeObserver.ts @@ -8,9 +8,17 @@ import { useStableCallback } from './useStableCallback'; /** * Хук вызывает переданный коллбэк при изменении размеров элемента. */ +export function useResizeObserver( + ref: React.RefObject | null | undefined, + callback: (element: HTMLElement) => void, +): void; +export function useResizeObserver( + ref: Window | null | undefined, + callback: (element: Window) => void, +): void; export function useResizeObserver( ref: React.RefObject | Window | null | undefined, - callback: (element: HTMLElement | Window) => void, + callback: (element: any) => void, ): void { const stableCallback = useStableCallback(callback); const { window } = useDOM(); From a778ed17df6d40f28abdbd02d8687a07cfe0dd04 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Fri, 16 Jan 2026 16:06:58 +0300 Subject: [PATCH 3/5] fix: useEffect without throttle --- .../Tint/CustomScrollViewTint.tsx | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx index add48d5688d..fd1bb12a090 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { classNames, throttle } from '@vkontakte/vkjs'; -import { RootComponent } from 'src/components/RootComponent/RootComponent'; import { useResizeObserver } from '../../../hooks/useResizeObserver'; import { type CSSCustomProperties, type HasRootRef } from '../../../types'; +import { RootComponent } from '../../RootComponent/RootComponent'; import styles from './CustomScrollViewTint.module.css'; export interface CustomScrollViewTintProps @@ -38,20 +38,18 @@ export function CustomScrollViewTint({ const [hasTintLeft, setHasTintLeft] = React.useState(false); const [hasTintRight, setHasTintRight] = React.useState(false); - const updateTint = React.useMemo( - () => - throttle((scrollElement: HTMLElement) => { - setHasTintTop(scrollElement.scrollTop > 0); - setHasTintBottom( - scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 0, - ); - setHasTintLeft(scrollElement.scrollLeft > 0); - setHasTintRight( - scrollElement.scrollWidth - scrollElement.clientWidth - scrollElement.scrollLeft > 0, - ); - }, 50), - [], - ); + const updateTint = React.useCallback((scrollElement: HTMLElement) => { + setHasTintTop(scrollElement.scrollTop > 0); + setHasTintBottom( + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 0, + ); + setHasTintLeft(scrollElement.scrollLeft > 0); + setHasTintRight( + scrollElement.scrollWidth - scrollElement.clientWidth - scrollElement.scrollLeft > 0, + ); + }, []); + + const updateTintThrottle = React.useMemo(() => throttle(updateTint, 50), [updateTint]); React.useEffect(() => { if (!scrollRef.current) { @@ -63,14 +61,14 @@ export function CustomScrollViewTint({ const onScroll = (e: React.UIEvent) => { const target = e.currentTarget; - updateTint(target); + updateTintThrottle(target); }; const baseStyle: CSSCustomProperties | undefined = tintColor ? { '--vkui_internal--CustomScrollView_tint_color': tintColor } : undefined; - useResizeObserver(scrollRef, updateTint); + useResizeObserver(scrollRef, updateTintThrottle); return ( From 021b56a5b4776802865111dfc69af38c909d4a4f Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Fri, 16 Jan 2026 17:23:24 +0300 Subject: [PATCH 4/5] fix: use mask-image --- .../Tint/CustomScrollViewTint.module.css | 61 +--------------- .../Tint/CustomScrollViewTint.stories.tsx | 1 - .../Tint/CustomScrollViewTint.test.tsx | 72 +++++++++---------- .../Tint/CustomScrollViewTint.tsx | 36 +++++----- packages/vkui/src/hooks/useResizeObserver.ts | 10 +-- 5 files changed, 56 insertions(+), 124 deletions(-) diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css index 9a668617949..ed43ff00072 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.module.css @@ -1,64 +1,5 @@ .host { - position: relative; inline-size: 100%; block-size: 100%; - - --vkui_internal--CustomScrollView_tint_color: var(--vkui--color_background_content); + mask-composite: intersect; } - -.tint { - position: absolute; -} - -/* stylelint-disable csstools/use-logical -- прокрутка рассчитывается по физическим величинам */ -.tintTop, -.tintBottom { - right: 0; - left: 0; - height: 40px; -} - -.tintTop { - top: 0; - background: linear-gradient( - to bottom, - var(--vkui_internal--CustomScrollView_tint_color), - transparent - ); -} - -.tintBottom { - bottom: 0; - background: linear-gradient( - to top, - var(--vkui_internal--CustomScrollView_tint_color), - transparent - ); -} - -.tintLeft, -.tintRight { - top: 0; - bottom: 0; - width: 40px; -} - -.tintLeft { - left: 0; - background: linear-gradient( - to right, - var(--vkui_internal--CustomScrollView_tint_color), - transparent - ); -} - -.tintRight { - right: 0; - background: linear-gradient( - to left, - var(--vkui_internal--CustomScrollView_tint_color), - transparent - ); -} - -/* stylelint-enable csstools/use-logical */ diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx index ed0f12a0c16..ca3352067a4 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.stories.tsx @@ -40,7 +40,6 @@ type Story = StoryObj; export const Playground: Story = { args: { - tintColor: 'var(--vkui--color_background)', children: (props) => ( ) => ( @@ -48,7 +47,11 @@ describe(CustomScrollViewTint, () => {
); - render({ScrollableDiv}); + render( + + {ScrollableDiv} + , + ); const scrollable = screen.getByTestId('scrollable'); @@ -56,8 +59,8 @@ describe(CustomScrollViewTint, () => { await act(() => vi.advanceTimersByTime(50)); - const tintTop = document.querySelector(`.${styles.tintTop}`); - expect(tintTop).toBeInTheDocument(); + 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 () => { @@ -72,7 +75,11 @@ describe(CustomScrollViewTint, () => {
); - render({ScrollableDiv}); + render( + + {ScrollableDiv} + , + ); const scrollable = screen.getByTestId('scrollable'); @@ -86,8 +93,8 @@ describe(CustomScrollViewTint, () => { await act(() => vi.advanceTimersByTime(50)); - const tintBottom = document.querySelector(`.${styles.tintBottom}`); - expect(tintBottom).toBeInTheDocument(); + 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 () => { @@ -102,7 +109,11 @@ describe(CustomScrollViewTint, () => {
); - render({ScrollableDiv}); + render( + + {ScrollableDiv} + , + ); const scrollable = screen.getByTestId('scrollable'); @@ -110,8 +121,8 @@ describe(CustomScrollViewTint, () => { await act(() => vi.advanceTimersByTime(50)); - const tintLeft = document.querySelector(`.${styles.tintLeft}`); - expect(tintLeft).toBeInTheDocument(); + 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 () => { @@ -126,7 +137,11 @@ describe(CustomScrollViewTint, () => {
); - render({ScrollableDiv}); + render( + + {ScrollableDiv} + , + ); const scrollable = screen.getByTestId('scrollable'); @@ -140,22 +155,8 @@ describe(CustomScrollViewTint, () => { await act(() => vi.advanceTimersByTime(50)); - const tintRight = document.querySelector(`.${styles.tintRight}`); - expect(tintRight).toBeInTheDocument(); - }); - - it('applies tintColor as CSS variable', () => { - render( - - {({ getRootRef, onScroll }) => ( -
- content -
- )} -
, - ); - const host = document.querySelector(`.${styles.host}`); - expect(host).toHaveStyle('--vkui_internal--CustomScrollView_tint_color: red'); + 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 () => { @@ -170,7 +171,11 @@ describe(CustomScrollViewTint, () => {
); - render({ScrollableDiv}); + render( + + {ScrollableDiv} + , + ); const scrollable = screen.getByTestId('scrollable'); @@ -184,14 +189,7 @@ describe(CustomScrollViewTint, () => { await act(() => vi.advanceTimersByTime(50)); - const tintTop = document.querySelector(`.${styles.tintTop}`); - const tintBottom = document.querySelector(`.${styles.tintBottom}`); - const tintLeft = document.querySelector(`.${styles.tintLeft}`); - const tintRight = document.querySelector(`.${styles.tintRight}`); - - expect(tintTop).not.toBeInTheDocument(); - expect(tintBottom).not.toBeInTheDocument(); - expect(tintLeft).not.toBeInTheDocument(); - expect(tintRight).not.toBeInTheDocument(); + 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 index fd1bb12a090..ee38acf5de5 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -1,19 +1,15 @@ 'use client'; import * as React from 'react'; -import { classNames, throttle } from '@vkontakte/vkjs'; +import { throttle } from '@vkontakte/vkjs'; import { useResizeObserver } from '../../../hooks/useResizeObserver'; -import { type CSSCustomProperties, type HasRootRef } from '../../../types'; +import { type HasRootRef } from '../../../types'; import { RootComponent } from '../../RootComponent/RootComponent'; import styles from './CustomScrollViewTint.module.css'; export interface CustomScrollViewTintProps extends Omit, 'children'>, HasRootRef { - /** - * Цвет тени. - */ - tintColor?: string; /** * Компонент-обертка для реализации прокрутки с тенями. */ @@ -22,13 +18,16 @@ export interface CustomScrollViewTintProps ) => 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, - tintColor, ...restProps }: CustomScrollViewTintProps): React.ReactElement { const scrollRef = React.useRef(null); @@ -38,6 +37,13 @@ export function CustomScrollViewTint({ 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 > 0); setHasTintBottom( @@ -64,19 +70,15 @@ export function CustomScrollViewTint({ updateTintThrottle(target); }; - const baseStyle: CSSCustomProperties | undefined = tintColor - ? { '--vkui_internal--CustomScrollView_tint_color': tintColor } - : undefined; - - useResizeObserver(scrollRef, updateTintThrottle); + useResizeObserver(scrollRef, updateTintThrottle as (element: HTMLElement | Window) => void); return ( - + {children({ getRootRef: scrollRef, onScroll })} - {hasTintTop &&
} - {hasTintBottom &&
} - {hasTintLeft &&
} - {hasTintRight &&
} ); } diff --git a/packages/vkui/src/hooks/useResizeObserver.ts b/packages/vkui/src/hooks/useResizeObserver.ts index 632c7b66477..e8fc57a9362 100644 --- a/packages/vkui/src/hooks/useResizeObserver.ts +++ b/packages/vkui/src/hooks/useResizeObserver.ts @@ -8,17 +8,9 @@ import { useStableCallback } from './useStableCallback'; /** * Хук вызывает переданный коллбэк при изменении размеров элемента. */ -export function useResizeObserver( - ref: React.RefObject | null | undefined, - callback: (element: HTMLElement) => void, -): void; -export function useResizeObserver( - ref: Window | null | undefined, - callback: (element: Window) => void, -): void; export function useResizeObserver( ref: React.RefObject | Window | null | undefined, - callback: (element: any) => void, + callback: (element: HTMLElement | Window) => void, ): void { const stableCallback = useStableCallback(callback); const { window } = useDOM(); From ad82164d2e5497fcb51b9ae27b746ec0b3c89c4e Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Mon, 2 Feb 2026 11:03:29 +0300 Subject: [PATCH 5/5] fix: use 1 --- .../CustomScrollView/Tint/CustomScrollViewTint.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx index ee38acf5de5..3e21df3937e 100644 --- a/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx +++ b/packages/vkui/src/components/CustomScrollView/Tint/CustomScrollViewTint.tsx @@ -45,13 +45,13 @@ export function CustomScrollViewTint({ ].filter(Boolean) as string[]; const updateTint = React.useCallback((scrollElement: HTMLElement) => { - setHasTintTop(scrollElement.scrollTop > 0); + setHasTintTop(scrollElement.scrollTop > 1); setHasTintBottom( - scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 0, + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1, ); - setHasTintLeft(scrollElement.scrollLeft > 0); + setHasTintLeft(scrollElement.scrollLeft > 1); setHasTintRight( - scrollElement.scrollWidth - scrollElement.clientWidth - scrollElement.scrollLeft > 0, + scrollElement.scrollWidth - scrollElement.clientWidth - scrollElement.scrollLeft > 1, ); }, []);