diff --git a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx index d5e9ef07b14..86b7ad482fd 100644 --- a/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx +++ b/packages/vkui/src/components/CustomScrollView/CustomScrollView.tsx @@ -3,6 +3,7 @@ import type * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import type { HasRootRef } from '../../types'; +import { RootComponent } from '../RootComponent/RootComponent'; 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'] | 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 +44,22 @@ 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} -
+ /> ); }; 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/useCustomScrollViewTint.tsx b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx new file mode 100644 index 00000000000..5dbd43edd8d --- /dev/null +++ b/packages/vkui/src/components/CustomScrollView/Tint/useCustomScrollViewTint.tsx @@ -0,0 +1,72 @@ +'use client'; + +import * as React from 'react'; +import { throttle } from '@vkontakte/vkjs'; +import { useResizeObserverElement } from '../../../hooks/useResizeObserver'; +import { type HasRootRef } from '../../../types'; + +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/#usecustomscrollviewtint + * @since 8.4.0 + */ +export function useCustomScrollViewTint(): UseCustomScrollViewTintResult { + 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 getRootRef = useResizeObserverElement(updateTintThrottle); + + React.useEffect(() => { + if (!getRootRef.current) { + return; + } + + updateTint(getRootRef.current); + }, [getRootRef, updateTint]); + + const onScroll = (e: React.UIEvent) => { + const target = e.currentTarget; + updateTintThrottle(target); + }; + + return { + onScroll, + getRootRef, + style: { + maskComposite: 'intersect', + maskImage: tint.join(', ') || 'none', + }, + }; +} 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/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/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); }, }); }; 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 1d5d24fed0c..c40ab28ebfa 100644 --- a/website/content/components/custom-scroll-view.mdx +++ b/website/content/components/custom-scroll-view.mdx @@ -73,6 +73,35 @@ tags: layout Компонент позволяет обрабатывать событие `scroll`, возникающее при прокрутке контента, через свойство `onScroll`. +## useCustomScrollViewTint + +Хук, который добавляет тени к краям прокручиваемой области через CSS-маску (`maskImage`). +Маска реагирует на положение прокрутки: тень появляется только у тех краёв, за которыми +есть ещё контент. + +Хук возвращает объект с тремя свойствами, которые нужно передать прокручиваемому элементу: + +- `getRootRef` — референс на прокручиваемый элемент; +- `onScroll` — обработчик события `scroll`; +- `style` — объект стилей с `maskImage` и `maskComposite` (необходимо применить к прокручиваемому элементу). + +{/* @example-description: Подсветка краёв прокручиваемой области через useCustomScrollViewTint. */} + + ```jsx + const { getRootRef, onScroll, style } = useCustomScrollViewTint(); + + return ( + + {['Say', 'Hello', 'To', 'My', 'Little', 'Friend'].map((item) => ( + }> + {item} + + ))} + + ); + ``` + + ## Свойства и методы [#api]