diff --git a/.editorconfig b/.editorconfig index 8f8e599..59c9794 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ charset = utf-8 end_of_line = lf indent_size = 2 insert_final_newline = true -max_line_length = 90 +max_line_length = 110 trim_trailing_whitespace = true [*.{md,mdx}] diff --git a/.eslintrc b/.eslintrc index 7281b83..9135d31 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,7 +33,14 @@ "react/jsx-indent": 0, "react/jsx-indent-props": 0, "react/prop-types": 0, - "@typescript-eslint/consistent-type-imports": "warn", + "react/react-in-jsx-scope": 0, + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + "prefer": "type-imports", + "fixStyle": "inline-type-imports" + } + ], "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/no-non-null-assertion": "warn", "@typescript-eslint/no-shadow": "warn", diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index adfd1c1..4dede90 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -9,20 +9,18 @@ jobs: steps: - name: 🛒 Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v4 - name: ⚒️ Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'pnpm' - name: 📦 Install Dependencies - run: | - corepack enable - pnpm install --frozen-lockfile --filter react-compare-slider + run: npm run bootstrap - name: 🔨 Build run: pnpm run --filter react-compare-slider build diff --git a/.prettierrc.js b/.prettierrc.js index 4115f01..278147a 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,7 +1,7 @@ /** @type {import('@ianvs/prettier-plugin-sort-imports').PrettierConfig} */ module.exports = { plugins: [require.resolve('@ianvs/prettier-plugin-sort-imports')], - printWidth: 100, + printWidth: 110, semi: true, singleQuote: true, trailingComma: 'all', diff --git a/.vscode/settings.json b/.vscode/settings.json index d7687c7..734facc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, - "editor.rulers": [100], + "editor.rulers": [110], "editor.formatOnSave": true, "search.exclude": { "**/node_modules": true, diff --git a/README.md b/README.md index 0e0c939..603994d 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,15 @@ GitHub CI status Coverage Demos + +Open in pkg.pr.new + -> [!IMPORTANT] +> [!IMPORTANT] > This readme is for the [v4 release](https://github.com/nerdyman/react-compare-slider/releases) which is currently in beta (`react-compare-slider@beta`). -> +> > See [Version 3](https://github.com/nerdyman/react-compare-slider/tree/v3.1.0) for the latest stable release (`react-compare-slider`). --- @@ -72,7 +75,7 @@ export const Example = () => { | Prop | Type | Required | Default | Description | | ---- | ---- | :------: | ------- | ----------- | -| [`boundsPadding`](https://react-compare-slider.vercel.app/?path=/story/demos--bounds-padding) | `number` | | `0` | Padding to limit the slideable bounds in pixels on the X-axis (landscape) or Y-axis (portrait). +| [`boundsPadding`](https://react-compare-slider.vercel.app/?path=/story/demos--bounds-padding) | `string` | | `0%` | Padding to limit the slideable bounds in pixels on the X-axis (landscape) or Y-axis (portrait). | [`browsingContext`](https://react-compare-slider.vercel.app/?path=/story/demos--browsing-context) | `globalThis` | | `globalThis` | Context to bind events to (useful for iframes). | [`clip`](https://react-compare-slider.vercel.app/?path=/docs/docs-clip--docs) | `` both\|itemOne\|itemTwo `` | | `both` | Whether to clip `itemOne`, `itemTwo` or `both` items. | [`changePositionOnHover`](https://react-compare-slider.vercel.app/?path=/story/demos--change-position-on-hover) | `boolean` | | `false` | Whether the slider should follow the pointer on hover. diff --git a/docs/storybook/content/02-bounds-padding.stories.mdx b/docs/storybook/content/02-bounds-padding.stories.mdx index 8f620e4..98905aa 100644 --- a/docs/storybook/content/02-bounds-padding.stories.mdx +++ b/docs/storybook/content/02-bounds-padding.stories.mdx @@ -7,8 +7,8 @@ import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slide # Using `boundsPadding` The `boundsPadding` prop allows you to limit the slideable area on the Y-axis -in `portrait` mode or on the X-axis in the default landscape mode. Negative values are treated the -same as `0` and the value **MUST** be supplied as a number in pixels. +in `portrait` mode or on the X-axis in the default landscape mode. Both absolute +and relative values are supported. E.g. `5%`, `10px`, `20rem`. Bounds padding is useful when the slider has other components overlaying it. E.g. in a full width or height carousel with overlaying navigation buttons. @@ -18,7 +18,7 @@ within range of pixels of the left or right of the slider specified by the `boun The bounds are automatically applied to the top/bottom or right/left depending on the orientation of the slider. ```JSX -boundsPadding={80} +boundsPadding="5%" ``` diff --git a/docs/storybook/content/stories/00-demos/00-index.stories.tsx b/docs/storybook/content/stories/00-demos/00-index.stories.tsx index a77e8a1..21b3e62 100644 --- a/docs/storybook/content/stories/00-demos/00-index.stories.tsx +++ b/docs/storybook/content/stories/00-demos/00-index.stories.tsx @@ -1,11 +1,7 @@ import type { Meta, StoryFn } from '@storybook/react'; import React, { useState } from 'react'; import type { ReactCompareSliderDetailedProps } from 'react-compare-slider'; -import { - ReactCompareSlider, - ReactCompareSliderImage, - useReactCompareSliderRef, -} from 'react-compare-slider'; +import { ReactCompareSlider, ReactCompareSliderImage, useReactCompareSliderRef } from 'react-compare-slider'; import { createPortal } from 'react-dom'; import { SLIDER_ROOT_TEST_ID } from '../99-tests/test-utils.test'; @@ -55,7 +51,7 @@ Images.args = { }; export const BoundsPadding: StoryFn = ({ - boundsPadding = 80, + boundsPadding = '5%', ...props }) => { return ( @@ -81,7 +77,7 @@ export const BoundsPadding: StoryFn = ({ ); }; -BoundsPadding.args = { boundsPadding: 80 }; +BoundsPadding.args = { boundsPadding: '5%' }; export const BrowsingContext: StoryFn = (props) => { const [browsingContext, setBrowsingContext] = useState(null); @@ -236,8 +232,7 @@ export const KeyboardIncrement: StoryFn = (prop return (
- Info: Click the slider handle then use the keyboard arrows to change the slider - position. + Info: Click the slider handle then use the keyboard arrows to change the slider position.
= (props return (
- Note: This demo will be slightly laggy when viewing the action logging output in - Storybook Actions tab. + Note: This demo will be slightly laggy when viewing the action logging output in Storybook + Actions tab.
= (props OnPositionChange.args = {}; -export const Portrait: StoryFn = ({ - portrait = true, - ...props -}) => ( +export const Portrait: StoryFn = ({ portrait = true, ...props }) => ( left: '50%', fontSize: '1.5rem', transform: 'translateX(-50%)', + zIndex: 1, }} > Reset sliders to position value ({props.position}) @@ -547,3 +540,68 @@ export const UseReactCompareSliderRef: StoryFn }; UseReactCompareSliderRef.args = {}; + +export const MultipleSliders: StoryFn = (props) => ( +
+
+ + } + itemTwo={ + + } + /> + + } + itemTwo={ + + } + /> +
+ + } + itemTwo={ + + } + /> +
+); + +MultipleSliders.args = {}; diff --git a/docs/storybook/content/stories/02-handles/00-react-compare-slider-handle.stories.tsx b/docs/storybook/content/stories/02-handles/00-react-compare-slider-handle.stories.tsx index 9eaacd8..a65edbe 100644 --- a/docs/storybook/content/stories/02-handles/00-react-compare-slider-handle.stories.tsx +++ b/docs/storybook/content/stories/02-handles/00-react-compare-slider-handle.stories.tsx @@ -169,20 +169,19 @@ export const OverrideHandleContainerClick: StoryFn = (p

- The useReactCompareSliderRef hook exposes the handleContainer{' '} - property which points to the button element that contains the{' '} - handle. By default, when the handleContainer or any elements - within it are clicked, it focuses the handleContainer and moves the slider - into view. This is for accessibility but you can override the behaviour as needed. In this - example, instead of plainly focusing the slider, it focuses and smooth scrolls it into - view. + The useReactCompareSliderRef hook exposes the handleContainer property + which points to the button element that contains the handle. By default, + when the handleContainer or any elements within it are clicked, it focuses the{' '} + handleContainer and moves the slider into view. This is for accessibility but you can + override the behaviour as needed. In this example, instead of plainly focusing the slider, it + focuses and smooth scrolls it into view.

Note that this only occurs when the  - handleContainer or elements within the handleContainer are - clicked. This is to allow custom itemOne|itemTwo components to be interacted - with without the slider stealing focus. + handleContainer or elements within the handleContainer are clicked. This + is to allow custom itemOne|itemTwo components to be interacted with without the slider + stealing focus.

{ const user = userEvent.setup(); const canvas = within(canvasElement); - const sliderRoot = canvas.queryByTestId( - KeyboardInteractionsLandscape.args?.['data-testid'], - ) as Element; + const sliderRoot = canvas.queryByTestId(KeyboardInteractionsLandscape.args?.['data-testid']) as Element; // Should have elements on mount. await new Promise((resolve) => setTimeout(resolve, 500)); @@ -28,23 +26,17 @@ KeyboardInteractionsLandscape.play = async ({ canvasElement }) => { // Focus the handle with tab key. await user.tab(); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container')); // Unfocus the handle with tab key. await user.tab({ shift: true }); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container')); // Focus the handle with mouse click. await fireEvent.click(canvas.getByRole('slider'), { clientX: 100, clientY: 100 }); await new Promise((resolve) => setTimeout(resolve, 500)); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container')); await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50')); // Move handle right. @@ -73,9 +65,7 @@ KeyboardInteractionsPortrait.args = getArgs({ KeyboardInteractionsPortrait.play = async ({ canvasElement }) => { const user = userEvent.setup(); const canvas = within(canvasElement); - const sliderRoot = canvas.queryByTestId( - KeyboardInteractionsPortrait.args?.['data-testid'], - ) as Element; + const sliderRoot = canvas.queryByTestId(KeyboardInteractionsPortrait.args?.['data-testid']) as Element; // Should have elements on mount. await new Promise((resolve) => setTimeout(resolve, 500)); @@ -84,25 +74,19 @@ KeyboardInteractionsPortrait.play = async ({ canvasElement }) => { // Focus the handle with tab key. await user.tab(); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container')); // Unfocus the handle with tab key. await user.tab({ shift: true }); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container')); // Focus the handle with mouse click. await fireEvent.click(canvas.getByRole('slider'), { clientX: 100, clientY: 100 }); await new Promise((resolve) => setTimeout(resolve, 500)); await waitFor(() => - expect((document.activeElement as HTMLElement).getAttribute('data-rcs')).toBe( - 'handle-container', - ), + expect((document.activeElement as HTMLElement).getAttribute('data-rcs')).toBe('handle-container'), ); await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50')); @@ -132,9 +116,7 @@ KeyboardInteractionsPixel.args = getArgs({ KeyboardInteractionsPixel.play = async ({ canvasElement }) => { const user = userEvent.setup(); const canvas = within(canvasElement); - const sliderRoot = canvas.queryByTestId( - KeyboardInteractionsPortrait.args?.['data-testid'], - ) as Element; + const sliderRoot = canvas.queryByTestId(KeyboardInteractionsPortrait.args?.['data-testid']) as Element; // Should have elements on mount. await new Promise((resolve) => setTimeout(resolve, 500)); @@ -143,25 +125,19 @@ KeyboardInteractionsPixel.play = async ({ canvasElement }) => { // Focus the handle with tab key. await user.tab(); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).toBe('handle-container')); // Unfocus the handle with tab key. await user.tab({ shift: true }); - await waitFor(() => - expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container'), - ); + await waitFor(() => expect(document.activeElement!.getAttribute('data-rcs')).not.toBe('handle-container')); // Focus the handle with mouse click. await fireEvent.click(canvas.getByRole('slider'), { clientX: 100, clientY: 100 }); await new Promise((resolve) => setTimeout(resolve, 500)); await waitFor(() => - expect((document.activeElement as HTMLElement).getAttribute('data-rcs')).toBe( - 'handle-container', - ), + expect((document.activeElement as HTMLElement).getAttribute('data-rcs')).toBe('handle-container'), ); await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('50')); diff --git a/docs/storybook/content/stories/99-tests/pointer-interactions.stories.tsx b/docs/storybook/content/stories/99-tests/pointer-interactions.stories.tsx index c7c8f15..de7f5e6 100644 --- a/docs/storybook/content/stories/99-tests/pointer-interactions.stories.tsx +++ b/docs/storybook/content/stories/99-tests/pointer-interactions.stories.tsx @@ -32,9 +32,7 @@ PointerMovementWithinBounds.play = async ({ canvasElement }) => { await new Promise((resolve) => setTimeout(resolve, 500)); await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('75')); - await waitFor(() => - expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(75), - ); + await waitFor(() => expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(75)); await new Promise((resolve) => setTimeout(resolve, 500)); @@ -46,9 +44,7 @@ PointerMovementWithinBounds.play = async ({ canvasElement }) => { await new Promise((resolve) => setTimeout(resolve, 500)); await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('100')); - await waitFor(() => - expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(100), - ); + await waitFor(() => expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(100)); await new Promise((resolve) => setTimeout(resolve, 500)); @@ -60,9 +56,7 @@ PointerMovementWithinBounds.play = async ({ canvasElement }) => { await new Promise((resolve) => setTimeout(resolve, 500)); await waitFor(() => expect(canvas.getByRole('slider').getAttribute('aria-valuenow')).toBe('5')); - await waitFor(() => - expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(5), - ); + await waitFor(() => expect(PointerMovementWithinBounds.args?.onPositionChange).toHaveBeenCalledWith(5)); }; /** @@ -84,9 +78,7 @@ ChangePositionOnHover.play = async ({ canvasElement }) => { const user = userEvent.setup(); const canvas = within(canvasElement); const slider = (await canvas.findByRole('slider')) as Element; - const sliderRoot = (await canvas.findByTestId( - ChangePositionOnHover.args?.['data-testid'], - )) as Element; + const sliderRoot = (await canvas.findByTestId(ChangePositionOnHover.args?.['data-testid'])) as Element; await waitFor(() => expect(sliderRoot).toBeInTheDocument()); @@ -125,9 +117,7 @@ ChangePositionOnHover.play = async ({ canvasElement }) => { /** * Ensure slider position continues to update when pointer is down and moved outside of the root. */ -export const ChangePositionOnHoverPointerDown: StoryFn = ( - props, -) => { +export const ChangePositionOnHoverPointerDown: StoryFn = (props) => { return (
diff --git a/docs/storybook/content/stories/99-tests/react-compare-slider-handle.test.stories.tsx b/docs/storybook/content/stories/99-tests/react-compare-slider-handle.test.stories.tsx index 1ea1800..355781d 100644 --- a/docs/storybook/content/stories/99-tests/react-compare-slider-handle.test.stories.tsx +++ b/docs/storybook/content/stories/99-tests/react-compare-slider-handle.test.stories.tsx @@ -27,22 +27,21 @@ ReactCompareSliderHandle.play = async ({ canvasElement }) => { // Lines should inherit color. await waitFor(() => expect( - window.getComputedStyle(handle?.querySelector('.__rcs-handle-line') as HTMLElement) - .backgroundColor, + window.getComputedStyle(handle?.querySelector('.__rcs-handle-line') as HTMLElement).backgroundColor, ).toBe('rgb(255, 0, 0)'), ); // Button should inherit color. await waitFor(() => - expect( - window.getComputedStyle(handle?.querySelector('.__rcs-handle-button') as HTMLElement).color, - ).toBe('rgb(255, 0, 0)'), + expect(window.getComputedStyle(handle?.querySelector('.__rcs-handle-button') as HTMLElement).color).toBe( + 'rgb(255, 0, 0)', + ), ); // Arrows should inherit color. await waitFor(() => - expect( - window.getComputedStyle(handle?.querySelector('.__rcs-handle-arrow') as HTMLElement).color, - ).toBe('rgb(255, 0, 0)'), + expect(window.getComputedStyle(handle?.querySelector('.__rcs-handle-arrow') as HTMLElement).color).toBe( + 'rgb(255, 0, 0)', + ), ); }; diff --git a/docs/storybook/content/stories/99-tests/react-compare-slider-image.test.stories.tsx b/docs/storybook/content/stories/99-tests/react-compare-slider-image.test.stories.tsx index 251abfc..7167655 100644 --- a/docs/storybook/content/stories/99-tests/react-compare-slider-image.test.stories.tsx +++ b/docs/storybook/content/stories/99-tests/react-compare-slider-image.test.stories.tsx @@ -25,9 +25,7 @@ ReactCompareSliderImage.args = { ReactCompareSliderImage.play = async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => - expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!)).toBeInTheDocument(), - ); + await waitFor(() => expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!)).toBeInTheDocument()); // Ensure default styles have been applied to `ReactCompareSliderImage`. await waitFor(() => @@ -38,9 +36,7 @@ ReactCompareSliderImage.play = async ({ canvasElement }) => { }; /** Default image. */ -export const ReactCompareSliderImageCustomStyle = (args) => ( - -); +export const ReactCompareSliderImageCustomStyle = (args) => ; ReactCompareSliderImageCustomStyle.args = { alt: 'testaroo', @@ -51,9 +47,7 @@ ReactCompareSliderImageCustomStyle.args = { ReactCompareSliderImageCustomStyle.play = async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => - expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!)).toBeInTheDocument(), - ); + await waitFor(() => expect(canvas.getByAltText(ReactCompareSliderImage.args!.alt!)).toBeInTheDocument()); // Ensure default styles have been applied to `ReactCompareSliderImage`. await waitFor(() => diff --git a/docs/storybook/content/stories/99-tests/test-utils.test.tsx b/docs/storybook/content/stories/99-tests/test-utils.test.tsx index 505345a..ff403dd 100644 --- a/docs/storybook/content/stories/99-tests/test-utils.test.tsx +++ b/docs/storybook/content/stories/99-tests/test-utils.test.tsx @@ -3,9 +3,7 @@ import type { StoryFn } from '@storybook/react'; import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; import type { ReactCompareSliderDetailedProps } from 'react-compare-slider'; -export const Template: StoryFn = (args) => ( - -); +export const Template: StoryFn = (args) => ; export const SLIDER_ROOT_TEST_ID = 'rcs-root'; diff --git a/docs/storybook/content/stories/99-tests/zero-bounds.test.stories.tsx b/docs/storybook/content/stories/99-tests/zero-bounds.test.stories.tsx index 0f4c189..262e5b3 100644 --- a/docs/storybook/content/stories/99-tests/zero-bounds.test.stories.tsx +++ b/docs/storybook/content/stories/99-tests/zero-bounds.test.stories.tsx @@ -73,9 +73,7 @@ export const ZeroBoundsWithLazyContent = () => { - +
diff --git a/docs/storybook/content/stories/config.ts b/docs/storybook/content/stories/config.ts index 8f28f85..4d1d001 100644 --- a/docs/storybook/content/stories/config.ts +++ b/docs/storybook/content/stories/config.ts @@ -5,7 +5,7 @@ import { ReactCompareSliderClipOption, type ReactCompareSliderProps } from 'reac * @NOTE These must reflect the default values defined in the `types.ts`. */ export const args: ReactCompareSliderProps = { - boundsPadding: 0, + boundsPadding: '0%', changePositionOnHover: false, clip: ReactCompareSliderClipOption.both, disabled: false, diff --git a/lib/src/Container.tsx b/lib/src/Container.tsx index f2a7104..63f68cf 100644 --- a/lib/src/Container.tsx +++ b/lib/src/Container.tsx @@ -1,27 +1,52 @@ -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import type { CSSProperties, ComponentPropsWithoutRef, ReactElement } from 'react'; -import type { ReactCompareSliderCommonProps } from './types'; +import { ReactCompareSliderCssVars } from './consts'; +import { + ReactCompareSliderClipOption, + type ReactCompareSliderClip, + type ReactCompareSliderCommonProps, + type ReactCompareSliderRootProps, +} from './types'; + +type GetClipPathProps = Pick & { + item: Extract; +}; + +const getClipPath = ({ item, portrait }: GetClipPathProps): CSSProperties['clipPath'] => { + if (item === ReactCompareSliderClipOption.itemOne) { + return portrait + ? `inset(0px 0px calc(100% - var(${ReactCompareSliderCssVars.currentPosition})) 0px)` + : `inset(0px calc(100% - var(${ReactCompareSliderCssVars.currentPosition})) 0px 0px)`; + } + + if (item === ReactCompareSliderClipOption.itemTwo) { + return portrait + ? `inset(var(${ReactCompareSliderCssVars.currentPosition}) 0px 0px 0px)` + : `inset(0px 0px 0px var(${ReactCompareSliderCssVars.currentPosition}))`; + } + + return 'none'; +}; type ContainerItemProps = ComponentPropsWithoutRef<'div'> & - Pick & { - shouldOverlap?: boolean; - order?: number; + Pick & { + item: Extract; }; /** Container for clipped item. */ export const ContainerItem = forwardRef( - ({ shouldOverlap, order, style, transition, ...props }, ref): ReactElement => { + ({ clip, item, portrait, style, transition, ...props }, ref): ReactElement => { const appliedStyle: CSSProperties = { - gridArea: '1 / 1 / 2 / 2', - order, + gridArea: '1 / 1', maxWidth: '100%', overflow: 'hidden', + clipPath: getClipPath({ item, portrait }), boxSizing: 'border-box', transition: transition ? `clip-path ${transition}` : undefined, userSelect: 'none', willChange: 'clip-path, transition', - zIndex: shouldOverlap ? 1 : undefined, + zIndex: clip === ReactCompareSliderClipOption.itemOne ? 1 : undefined, KhtmlUserSelect: 'none', MozUserSelect: 'none', WebkitUserSelect: 'none', @@ -34,16 +59,18 @@ export const ContainerItem = forwardRef( ContainerItem.displayName = 'ContainerItem'; -type ContainerHandleProps = ComponentPropsWithoutRef<'button'> & ReactCompareSliderCommonProps; +type ContainerHandleProps = ComponentPropsWithoutRef<'button'> & + Omit; /** Container to control the handle's position. */ export const ContainerHandle = forwardRef( - ({ children, disabled, portrait, position, transition }, ref): ReactElement => { + ({ children, disabled, portrait, transition }, ref): ReactElement => { const targetAxis = portrait ? 'top' : 'left'; const style: CSSProperties = { position: 'absolute', - top: 0, + top: portrait ? `var(${ReactCompareSliderCssVars.currentPosition})` : '0', + left: portrait ? '0' : `var(${ReactCompareSliderCssVars.currentPosition})`, width: portrait ? '100%' : undefined, height: portrait ? undefined : '100%', background: 'none', @@ -66,7 +93,6 @@ export const ContainerHandle = forwardRef { }; /** Root Comparison slider. */ -export const ReactCompareSlider = forwardRef< - UseReactCompareSliderRefReturn, - ReactCompareSliderDetailedProps ->( +export const ReactCompareSlider = forwardRef( ( { - boundsPadding = 0, + boundsPadding = '0%', browsingContext = globalThis, changePositionOnHover = false, clip = ReactCompareSliderClipOption.both, @@ -65,13 +61,9 @@ export const ReactCompareSlider = forwardRef< ...props }, ref, - ): ReactElement => { + ) => { /** DOM node of the root element. */ const rootContainerRef = useRef(null); - /** DOM node `itemOne` container. */ - const clipContainerOneRef = useRef(null); - /** DOM node of `itemTwo`. */ - const clipContainerTwoRef = useRef(null); /** DOM node of the handle container. */ const handleContainerRef = useRef(null); /** Current position as a percentage value (initially negative to sync bounds on mount). */ @@ -87,19 +79,31 @@ export const ReactCompareSlider = forwardRef< /** The `position` value at *previous* render. */ const previousPosition = usePrevious(position); + const setPosition = useCallback( + (nextPosition: number) => { + const appliedPosition = Math.min(Math.max(nextPosition, 0), 100); + + rootContainerRef.current?.style.setProperty( + ReactCompareSliderCssVars.currentPosition, + `clamp(var(${ReactCompareSliderCssVars.boundsPadding}), ${appliedPosition}% - var(${ReactCompareSliderCssVars.boundsPadding}) + var(${ReactCompareSliderCssVars.boundsPadding}), calc(100% - var(${ReactCompareSliderCssVars.boundsPadding})))`, + ); + + handleContainerRef.current?.setAttribute('aria-valuenow', `${Math.round(nextPosition)}`); + internalPosition.current = appliedPosition; + + onPositionChange?.(nextPosition); + }, + [onPositionChange], + ); + /** Sync the internal position and trigger position change callback if defined. */ - const updateInternalPosition = useCallback( - function updateInternal({ x, y, isOffset }: UpdateInternalPositionProps) { + const setPositionFromBounds = useCallback( + function updateInternal({ x, y, isOffset }: SetPositionFromBoundsProps) { const rootElement = rootContainerRef.current as HTMLDivElement; - const handleElement = handleContainerRef.current as HTMLButtonElement; - const clipElementOne = clipContainerOneRef.current as HTMLDivElement; - const clipElementTwo = clipContainerTwoRef.current as HTMLDivElement; - const { width, height, left, top } = rootElement.getBoundingClientRect(); + const { width, height, top, left } = rootElement.getBoundingClientRect(); // Early out when component has zero bounds. - if (width === 0 || height === 0) { - return; - } + if (width === 0 || height === 0) return; const pixelPosition = portrait ? isOffset @@ -108,69 +112,24 @@ export const ReactCompareSlider = forwardRef< : isOffset ? x - left - browsingContext.scrollX : x; + const nextPosition = (pixelPosition / (portrait ? height : width)) * 100; - /** Next position as percentage. */ - const nextPosition = Math.min( - Math.max((pixelPosition / (portrait ? height : width)) * 100, 0), - 100, - ); - - const zoomScale = portrait - ? height / (rootElement.offsetHeight || 1) - : width / (rootElement.offsetWidth || 1); - - const boundsPaddingPercentage = - ((boundsPadding * zoomScale) / (portrait ? height : width)) * 100; - - const nextPositionWithBoundsPadding = Math.min( - Math.max(nextPosition, boundsPaddingPercentage * zoomScale), - 100 - boundsPaddingPercentage * zoomScale, - ); - - internalPosition.current = nextPosition; - handleElement.setAttribute('aria-valuenow', `${Math.round(internalPosition.current)}`); - handleElement.style.top = portrait ? `${nextPositionWithBoundsPadding}%` : '0'; - handleElement.style.left = portrait ? '0' : `${nextPositionWithBoundsPadding}%`; - - const clipBoth = clip === ReactCompareSliderClipOption.both; - - if (clipBoth || clip === ReactCompareSliderClipOption.itemOne) { - clipElementOne.style.clipPath = portrait - ? `inset(0 0 ${100 - nextPositionWithBoundsPadding}% 0)` - : `inset(0 ${100 - nextPositionWithBoundsPadding}% 0 0)`; - } else { - clipElementOne.style.clipPath = 'none'; - } - - if (clipBoth || clip === ReactCompareSliderClipOption.itemTwo) { - clipElementTwo.style.clipPath = portrait - ? `inset(${nextPositionWithBoundsPadding}% 0 0 0)` - : `inset(0 0 0 ${nextPositionWithBoundsPadding}%)`; - } else { - clipElementTwo.style.clipPath = 'none'; - } - - if (onPositionChange) { - onPositionChange(internalPosition.current); - } + setPosition(nextPosition); }, - [browsingContext, boundsPadding, clip, onPositionChange, portrait], + [browsingContext, portrait, setPosition], ); - // Update internal position when other user controllable props change. + // Update internal position on change. useEffect(() => { - const { width, height } = ( - rootContainerRef.current as HTMLDivElement - ).getBoundingClientRect(); - - // Use current internal position if `position` hasn't changed. - const nextPosition = position === previousPosition ? internalPosition.current : position; + if (position !== previousPosition) { + setPosition(position); + } + }, [position, previousPosition, setPosition]); - updateInternalPosition({ - x: (width / 100) * nextPosition, - y: (height / 100) * nextPosition, - }); - }, [boundsPadding, clip, position, portrait, previousPosition, updateInternalPosition]); + // Update bounds padding on change. + useEffect(() => { + rootContainerRef.current?.style.setProperty(ReactCompareSliderCssVars.boundsPadding, boundsPadding); + }, [boundsPadding]); /** Handle mouse/touch down. */ const handlePointerDown = useCallback( @@ -180,20 +139,20 @@ export const ReactCompareSlider = forwardRef< // Only handle left mouse button (touch events also use 0). if (disabled || ev.button !== 0) return; - updateInternalPosition({ isOffset: true, x: ev.pageX, y: ev.pageY }); + setPositionFromBounds({ x: ev.pageX, y: ev.pageY, isOffset: true }); setIsDragging(true); setCanTransition(true); }, - [disabled, updateInternalPosition], + [disabled, setPositionFromBounds], ); /** Handle mouse/touch move. */ const handlePointerMove = useCallback( function moveCall(ev: PointerEvent) { - updateInternalPosition({ isOffset: true, x: ev.pageX, y: ev.pageY }); + setPositionFromBounds({ x: ev.pageX, y: ev.pageY, isOffset: true }); setCanTransition(false); }, - [updateInternalPosition], + [setPositionFromBounds], ); /** Handle mouse/touch up. */ @@ -207,21 +166,6 @@ export const ReactCompareSlider = forwardRef< setCanTransition(true); }, []); - /** Resync internal position on resize. */ - const handleResize: (resizeProps: UseResizeObserverHandlerProps) => void = useCallback( - ({ width, height }) => { - const { width: scaledWidth, height: scaledHeight } = ( - rootContainerRef.current as HTMLDivElement - ).getBoundingClientRect(); - - updateInternalPosition({ - x: ((width / 100) * internalPosition.current * scaledWidth) / width, - y: ((height / 100) * internalPosition.current * scaledHeight) / height, - }); - }, - [updateInternalPosition], - ); - /** Handle keyboard movment. */ const handleKeydown = useCallback( (ev: KeyboardEvent) => { @@ -232,46 +176,41 @@ export const ReactCompareSlider = forwardRef< ev.preventDefault(); setCanTransition(true); - const { top, left } = ( - handleContainerRef.current as HTMLButtonElement - ).getBoundingClientRect(); - - const { width, height } = ( - rootContainerRef.current as HTMLDivElement - ).getBoundingClientRect(); + const { top, left } = (handleContainerRef.current as HTMLButtonElement).getBoundingClientRect(); + const { width, height } = (rootContainerRef.current as HTMLDivElement).getBoundingClientRect(); - const isPercentage = typeof keyboardIncrement === 'string'; - const incrementPercentage = isPercentage - ? parseFloat(keyboardIncrement) - : (keyboardIncrement / width) * 100; + const incrementPercentage = + typeof keyboardIncrement === 'string' + ? parseFloat(keyboardIncrement) + : (keyboardIncrement / width) * 100; const isIncrement = portrait ? ev.key === KeyboardEventKeys.ARROW_LEFT || ev.key === KeyboardEventKeys.ARROW_DOWN : ev.key === KeyboardEventKeys.ARROW_RIGHT || ev.key === KeyboardEventKeys.ARROW_UP; + const currentPosition = parseFloat( + handleContainerRef.current?.getAttribute?.('aria-valuenow') ?? '0', + ); + const nextPosition = Math.min( Math.max( - isIncrement - ? internalPosition.current + incrementPercentage - : internalPosition.current - incrementPercentage, + isIncrement ? currentPosition + incrementPercentage : currentPosition - incrementPercentage, 0, ), 100, ); - updateInternalPosition({ + setPositionFromBounds({ x: portrait ? left : (width * nextPosition) / 100, y: portrait ? (height * nextPosition) / 100 : top, }); }, - [keyboardIncrement, portrait, updateInternalPosition], + [keyboardIncrement, portrait, setPositionFromBounds], ); // Set target container for pointer events. useEffect(() => { - setInteractiveTarget( - onlyHandleDraggable ? handleContainerRef.current : rootContainerRef.current, - ); + setInteractiveTarget(onlyHandleDraggable ? handleContainerRef.current : rootContainerRef.current); }, [onlyHandleDraggable]); // Handle hover events on the container. @@ -318,50 +257,22 @@ export const ReactCompareSlider = forwardRef< rootContainer: rootContainerRef.current, handleContainer: handleContainerRef.current, setPosition(nextPosition): void { - const { width, height } = ( - rootContainerRef.current as HTMLDivElement - ).getBoundingClientRect(); + const { width, height } = (rootContainerRef.current as HTMLDivElement).getBoundingClientRect(); - updateInternalPosition({ + setPositionFromBounds({ x: (width / 100) * nextPosition, y: (height / 100) * nextPosition, }); }, }; }, - [updateInternalPosition], + [setPositionFromBounds], ); - // Bind resize observer to container. - useResizeObserver(rootContainerRef, handleResize); - - useEventListener( - 'touchend', - handleTouchEnd, - interactiveTarget as HTMLDivElement, - EVENT_CAPTURE_PARAMS, - ); - - useEventListener( - 'keydown', - handleKeydown, - handleContainerRef.current as HTMLButtonElement, - EVENT_CAPTURE_PARAMS, - ); - - useEventListener( - 'click', - handleContainerClick, - handleContainerRef.current as HTMLButtonElement, - EVENT_CAPTURE_PARAMS, - ); - - useEventListener( - 'pointerdown', - handlePointerDown, - interactiveTarget as HTMLDivElement, - EVENT_CAPTURE_PARAMS, - ); + useEventListener('touchend', handleTouchEnd, interactiveTarget, EVENT_CAPTURE_PARAMS); + useEventListener('keydown', handleKeydown, handleContainerRef.current, EVENT_CAPTURE_PARAMS); + useEventListener('click', handleContainerClick, handleContainerRef.current, EVENT_CAPTURE_PARAMS); + useEventListener('pointerdown', handlePointerDown, interactiveTarget, EVENT_CAPTURE_PARAMS); // Use custom handle if requested. const Handle = handle || ; @@ -384,23 +295,28 @@ export const ReactCompareSlider = forwardRef< }; return ( -
+
{itemOne} - + {itemTwo} diff --git a/lib/src/ReactCompareSliderHandle.tsx b/lib/src/ReactCompareSliderHandle.tsx index 6f6ca75..b97e23a 100644 --- a/lib/src/ReactCompareSliderHandle.tsx +++ b/lib/src/ReactCompareSliderHandle.tsx @@ -1,12 +1,11 @@ -import React from 'react'; import type { CSSProperties, FC, HtmlHTMLAttributes, ReactElement } from 'react'; import type { ReactCompareSliderCommonProps } from './types'; -interface ThisArrowProps { +type ThisArrowProps = { /** Whether to flip the arrow direction. */ flip?: boolean; -} +}; const ThisArrow: FC = ({ flip }) => { const style: CSSProperties = { @@ -22,15 +21,14 @@ const ThisArrow: FC = ({ flip }) => { }; /** Props for `ReactCompareSliderHandle`. */ -export interface ReactCompareSliderHandleProps - extends Pick { +export type ReactCompareSliderHandleProps = Pick & { /** Optional styles for handle the button. */ buttonStyle?: CSSProperties; /** Optional styles for lines either side of the handle button. */ linesStyle?: CSSProperties; /** Optional styles for the handle root. */ style?: CSSProperties; -} +}; /** Default `handle`. */ export const ReactCompareSliderHandle: FC< diff --git a/lib/src/ReactCompareSliderImage.tsx b/lib/src/ReactCompareSliderImage.tsx index d83c409..a13bc50 100644 --- a/lib/src/ReactCompareSliderImage.tsx +++ b/lib/src/ReactCompareSliderImage.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import type { CSSProperties, ImgHTMLAttributes, ReactElement } from 'react'; +import type { ImgHTMLAttributes, ReactElement } from 'react'; import { forwardRef } from 'react'; import { styleFitContainer } from './utils'; @@ -10,7 +9,7 @@ export type ReactCompareSliderImageProps = ImgHTMLAttributes; /** `Img` element with defaults from `styleFitContainer` applied. */ export const ReactCompareSliderImage = forwardRef( ({ style, ...props }, ref): ReactElement => { - const rootStyle: CSSProperties = styleFitContainer(style); + const rootStyle = styleFitContainer(style); return ; }, diff --git a/lib/src/consts.ts b/lib/src/consts.ts new file mode 100644 index 0000000..eb5d5ff --- /dev/null +++ b/lib/src/consts.ts @@ -0,0 +1,6 @@ +export const ReactCompareSliderCssVars = { + /** The current `position` of the slider. */ + currentPosition: '--rcs-current-position', + /** The `boundsPadding` value. */ + boundsPadding: '--rcs-bounds-padding', +} as const; diff --git a/lib/src/index.ts b/lib/src/index.ts index 94f8477..64e388f 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,3 +1,4 @@ +export { ReactCompareSliderCssVars } from './consts'; export { ReactCompareSlider } from './ReactCompareSlider'; export { ReactCompareSliderHandle } from './ReactCompareSliderHandle'; diff --git a/lib/src/types.ts b/lib/src/types.ts index cd7e848..8fdad94 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -4,7 +4,7 @@ import type { HtmlHTMLAttributes, ReactNode, RefAttributes } from 'react'; export type ReactCompareSliderPropPosition = number; /** Common props shared between components. */ -export interface ReactCompareSliderCommonProps { +export type ReactCompareSliderCommonProps = { /** * Whether to disable slider movement (items are still interactable). * @default false @@ -29,7 +29,7 @@ export interface ReactCompareSliderCommonProps { * @example '.5s ease-in-out' */ transition?: string; -} +}; export const ReactCompareSliderClipOption = { both: 'both', @@ -41,12 +41,13 @@ export type ReactCompareSliderClip = (typeof ReactCompareSliderClipOption)[keyof typeof ReactCompareSliderClipOption]; /** Slider component props *without* ref return props. */ -export interface ReactCompareSliderRootProps extends Partial { +export type ReactCompareSliderRootProps = Partial & { /** - * Padding in pixels to limit the slideable bounds on the X-axis (landscape) or Y-axis (portrait). - * @default 0 + * CSS unit amount to limit the slideable bounds on the X-axis (landscape) or Y-axis (portrait). + * @example '20rem' + * @default '0%' */ - boundsPadding?: number; + boundsPadding?: string; /** * Custom browsing context to use instead of the global `window` object. @@ -87,7 +88,7 @@ export interface ReactCompareSliderRootProps extends Partial void; -} +}; /** Properties returned by the `useReactCompareSliderRef` hook. */ export type UseReactCompareSliderRefReturn = { @@ -116,5 +117,4 @@ export type ReactCompareSliderProps = ReactCompareSliderRootProps & RefAttributes; /** `ReactCompareSliderProps` and all valid `div` element props. */ -export type ReactCompareSliderDetailedProps = ReactCompareSliderProps & - HtmlHTMLAttributes; +export type ReactCompareSliderDetailedProps = ReactCompareSliderProps & HtmlHTMLAttributes; diff --git a/lib/src/useReactCompareSliderRef.ts b/lib/src/useReactCompareSliderRef.ts index d5c3570..4462525 100644 --- a/lib/src/useReactCompareSliderRef.ts +++ b/lib/src/useReactCompareSliderRef.ts @@ -12,7 +12,5 @@ export const useReactCompareSliderRef = (): MutableRefObject // eslint-disable-next-line no-console - console.warn( - '[react-compare-slider] `setPosition` cannot be used until the component has mounted.', - ), + console.warn('[react-compare-slider] `setPosition` cannot be used until the component has mounted.'), }); diff --git a/lib/src/utils.ts b/lib/src/utils.ts index 1efe404..21fbe4e 100644 --- a/lib/src/utils.ts +++ b/lib/src/utils.ts @@ -1,5 +1,4 @@ -import type { CSSProperties, RefObject } from 'react'; -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useEffect, useRef, type CSSProperties } from 'react'; /** Keyboard `key` events to trigger slider movement. */ export enum KeyboardEventKeys { @@ -29,8 +28,8 @@ export const styleFitContainer = ({ }); /** Store the previous supplied value. */ -export const usePrevious = (value: T): T => { - const ref = useRef(value); +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(); useEffect(() => { ref.current = value; @@ -49,7 +48,7 @@ export const usePrevious = (value: T): T => { export const useEventListener = ( eventName: EventListener['name'], handler: EventListener['caller'], - element: EventTarget, + element: EventTarget | undefined | null, handlerOptions: AddEventListenerOptions, ): void => { const savedHandler = useRef(); @@ -60,11 +59,10 @@ export const useEventListener = ( useEffect(() => { // Make sure element supports addEventListener. - if (!(element && element.addEventListener)) return; + if (!element?.addEventListener) return; // Create event listener that calls handler function stored in ref. - const eventListener: EventListener = (event) => - savedHandler.current && savedHandler.current(event); + const eventListener: EventListener = (event) => savedHandler.current && savedHandler.current(event); element.addEventListener(eventName, eventListener, handlerOptions); @@ -73,43 +71,3 @@ export const useEventListener = ( }; }, [eventName, element, handlerOptions]); }; - -/** - * Conditionally use `useLayoutEffect` for client *or* `useEffect` for SSR. - * @see https://github.com/reduxjs/react-redux/blob/89a86805f2fcf9e8fbd2d1dae345ec791de4a71f/src/utils/useIsomorphicLayoutEffect.ts - */ -const useIsomorphicLayoutEffect = - typeof window !== 'undefined' && - typeof window.document !== 'undefined' && - typeof window.document.createElement !== 'undefined' - ? useLayoutEffect - : useEffect; - -/** Params passed to `useResizeObserver` `handler` function. */ -export type UseResizeObserverHandlerProps = DOMRect; - -/** - * Bind resize observer callback to element. - * @param ref - Element to bind to. - * @param handler - Callback for handling entry's bounding rect. - */ -export const useResizeObserver = ( - ref: RefObject, - handler: (entry: UseResizeObserverHandlerProps) => void, -): void => { - const observer = useRef(); - - const observe = useCallback(() => { - if (ref.current && observer.current) observer.current.observe(ref.current); - }, [ref]); - - // Bind/rebind observer when `handler` changes. - useIsomorphicLayoutEffect(() => { - observer.current = new ResizeObserver(([entry]) => handler(entry!.contentRect)); - observe(); - - return (): void => { - if (observer.current) observer.current.disconnect(); - }; - }, [handler, observe]); -};