Skip to content

Commit

Permalink
feat: add accessibility features, resolves #65
Browse files Browse the repository at this point in the history
  • Loading branch information
nerdyman committed Sep 11, 2022
1 parent 6343b38 commit 8a6efeb
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 66 deletions.
61 changes: 34 additions & 27 deletions src/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const ContainerClip = forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivE
left: 0,
width: '100%',
height: '100%',
willChange: 'clip',
willChange: 'clip-path',
userSelect: 'none',
KhtmlUserSelect: 'none',
MozUserSelect: 'none',
Expand All @@ -26,32 +26,39 @@ ContainerClip.displayName = 'ContainerClip';

/** Container to control the handle's position. */
export const ContainerHandle = forwardRef<
HTMLDivElement,
React.HTMLProps<HTMLDivElement> & Pick<ReactCompareSliderCommonProps, 'portrait'>
>(
({ children, portrait }, ref): React.ReactElement => {
const style: React.CSSProperties = {
position: 'absolute',
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
};
HTMLButtonElement,
React.HTMLProps<HTMLButtonElement> &
Pick<ReactCompareSliderCommonProps, 'portrait' | 'position'>
>(({ children, portrait, position }, ref): React.ReactElement => {
const style: React.CSSProperties = {
position: 'absolute',
top: 0,
width: portrait ? '100%' : undefined,
height: portrait ? undefined : '100%',
background: 'none',
border: 0,
padding: 0,
pointerEvents: 'all',
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
outline: 0,
};

const innerStyle: React.CSSProperties = {
position: 'absolute',
width: portrait ? '100%' : undefined,
height: portrait ? undefined : '100%',
transform: portrait ? 'translateY(-50%)' : 'translateX(-50%)',
pointerEvents: 'all',
};

return (
<div style={style} data-rcs="handle-container" ref={ref}>
<div style={innerStyle}>{children}</div>
</div>
);
}
);
return (
<button
ref={ref}
aria-orientation={portrait ? 'vertical' : 'horizontal'}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={position}
data-rcs="handle-container"
role="slider"
style={style}
>
{children}
</button>
);
});

ContainerHandle.displayName = 'ThisHandleContainer';
113 changes: 80 additions & 33 deletions src/ReactCompareSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,18 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';

import { ContainerClip, ContainerHandle } from './Container';
import { ReactCompareSliderHandle } from './ReactCompareSliderHandle';
import { ReactCompareSliderCommonProps, ReactCompareSliderPropPosition } from './types';
import type { ReactCompareSliderAllProps } from './types';
import {
KeyboardEventKeys,
useEventListener,
usePrevious,
UseResizeObserverHandlerParams,
useResizeObserver,
} from './utils';

/** Comparison slider properties. */
export interface ReactCompareSliderProps extends Partial<ReactCompareSliderCommonProps> {
/** Padding to limit the slideable bounds in pixels on the X-axis (landscape) or Y-axis (portrait). */
boundsPadding?: number;
/** Whether the slider should follow the pointer on hover. */
changePositionOnHover?: boolean;
/** Custom handle component. */
handle?: React.ReactNode;
/** First item to show. */
itemOne: React.ReactNode;
/** Second item to show. */
itemTwo: React.ReactNode;
/** Whether to only change position when handle is interacted with (useful for touch devices). */
onlyHandleDraggable?: boolean;
/** Callback on position change with position as percentage. */
onPositionChange?: (position: ReactCompareSliderPropPosition) => void;
}

/** Properties for internal `updateInternalPosition` callback. */
interface UpdateInternalPositionProps
extends Required<Pick<ReactCompareSliderProps, 'boundsPadding' | 'portrait'>> {
extends Required<Pick<ReactCompareSliderAllProps, 'boundsPadding' | 'portrait'>> {
/** X coordinate to update to (landscape). */
x: number;
/** Y coordinate to update to (portrait). */
Expand All @@ -43,9 +26,7 @@ const EVENT_PASSIVE_PARAMS = { passive: true };
const EVENT_CAPTURE_PARAMS = { capture: true, passive: false };

/** Root Comparison slider. */
export const ReactCompareSlider: React.FC<
ReactCompareSliderProps & React.HtmlHTMLAttributes<HTMLDivElement>
> = ({
export const ReactCompareSlider: React.FC<ReactCompareSliderAllProps> = ({
handle,
itemOne,
itemTwo,
Expand All @@ -55,6 +36,7 @@ export const ReactCompareSlider: React.FC<
position = 50,
boundsPadding = 0,
changePositionOnHover = false,
keyboardMovementIncrement = 20,
style,
...props
}): React.ReactElement => {
Expand All @@ -63,7 +45,7 @@ export const ReactCompareSlider: React.FC<
/** DOM node of the item that is clipped. */
const clipContainerRef = useRef<HTMLDivElement>(null);
/** DOM node of the handle container. */
const handleContainerRef = useRef<HTMLDivElement>(null);
const handleContainerRef = useRef<HTMLButtonElement>(null);
/** Current position as a percentage value (initially negative to sync bounds on mount). */
const internalPositionPc = useRef(position);
/** Previous `position` prop value. */
Expand All @@ -73,7 +55,7 @@ export const ReactCompareSlider: React.FC<
/** Whether component has a `window` event binding. */
const hasWindowBinding = useRef(false);
/** Target container for pointer events. */
const [interactiveTarget, setInteractiveTarget] = useState<HTMLDivElement | null>();
const [interactiveTarget, setInteractiveTarget] = useState<HTMLElement | null>();
/** Whether the bounds of the container element have been synchronised. */
const [didSyncBounds, setDidSyncBounds] = useState(false);

Expand Down Expand Up @@ -169,13 +151,18 @@ export const ReactCompareSlider: React.FC<
(_portrait ? adjustedHeight : adjustedWidth) - _boundsPadding
);

(clipContainerRef.current as HTMLElement).style.clip = _portrait
? `rect(auto,auto,${clampedPx}px,auto)`
: `rect(auto,${clampedPx}px,auto,auto)`;
(handleContainerRef.current as HTMLButtonElement).setAttribute(
'aria-valuenow',
`${Math.round(internalPositionPc.current)}`
);

(clipContainerRef.current as HTMLElement).style.clipPath = _portrait
? `inset(${clampedPx}px 0 0 0)`
: `inset(0 0 0 ${clampedPx}px)`;

(handleContainerRef.current as HTMLElement).style.transform = _portrait
? `translate3d(0,${clampedPx}px,0)`
: `translate3d(${clampedPx}px,0,0)`;
? `translate3d(0,calc(-50% + ${clampedPx}px),0)`
: `translate3d(calc(-50% + ${clampedPx}px),0,0)`;

if (onPositionChange) onPositionChange(internalPositionPc.current);
},
Expand Down Expand Up @@ -254,6 +241,47 @@ export const ReactCompareSlider: React.FC<
[portrait, boundsPadding, updateInternalPosition]
);

/**
* Yo dawg, we heard you like handles, so we handled in your handle so you can handle
* while you handle.
*/
const handleHandleClick = useCallback((ev: MouseEvent) => {
ev.stopPropagation();
(handleContainerRef.current as HTMLButtonElement).focus();
}, []);

/** Handle keyboard movment. */
const handleKeydown = useCallback(
(ev: KeyboardEvent) => {
if (!Object.values(KeyboardEventKeys).includes(ev.key as KeyboardEventKeys)) return;
ev.preventDefault();

const { top, right, bottom, left } = (
handleContainerRef.current as HTMLButtonElement
).getBoundingClientRect();

const isIncrement =
ev.key == KeyboardEventKeys.ARROW_UP || ev.key == KeyboardEventKeys.ARROW_RIGHT;

const offsetX = isIncrement
? right - keyboardMovementIncrement
: left + keyboardMovementIncrement;

const offsetY = isIncrement
? top + keyboardMovementIncrement
: bottom - keyboardMovementIncrement;

updateInternalPosition({
portrait,
boundsPadding,
x: portrait ? left : offsetX,
y: portrait ? offsetY : top,
isOffset: true,
});
},
[boundsPadding, keyboardMovementIncrement, portrait, updateInternalPosition]
);

// Allow drag outside of container while pointer is still down.
useEffect(() => {
if (isDragging && !hasWindowBinding.current) {
Expand Down Expand Up @@ -298,6 +326,20 @@ export const ReactCompareSlider: React.FC<
};
}, [changePositionOnHover, handlePointerMove, handlePointerUp, isDragging]);

useEventListener(
'keydown',
handleKeydown,
handleContainerRef.current as HTMLButtonElement,
EVENT_CAPTURE_PARAMS
);

useEventListener(
'click',
handleHandleClick,
handleContainerRef.current as HTMLButtonElement,
EVENT_CAPTURE_PARAMS
);

useEventListener(
'mousedown',
handlePointerDown,
Expand Down Expand Up @@ -329,9 +371,14 @@ export const ReactCompareSlider: React.FC<

return (
<div {...props} ref={rootContainerRef} style={rootStyle} data-rcs="root">
{itemTwo}
<ContainerClip ref={clipContainerRef}>{itemOne}</ContainerClip>
<ContainerHandle portrait={portrait} ref={handleContainerRef}>
{itemOne}
<ContainerClip ref={clipContainerRef}>{itemTwo}</ContainerClip>

<ContainerHandle
portrait={portrait}
ref={handleContainerRef}
position={Math.round(internalPositionPc.current)}
>
{Handle}
</ContainerHandle>
</div>
Expand Down
14 changes: 11 additions & 3 deletions src/ReactCompareSliderHandle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ export interface ReactCompareSliderHandleProps
}

/** Default `handle`. */
export const ReactCompareSliderHandle: React.FC<ReactCompareSliderHandleProps> = ({
portrait,
export const ReactCompareSliderHandle: React.FC<
ReactCompareSliderHandleProps & React.HtmlHTMLAttributes<HTMLDivElement>
> = ({
className = '__rcs-handle-root',
buttonStyle,
linesStyle,
portrait,
style,
...props
}): React.ReactElement => {
Expand Down Expand Up @@ -80,7 +83,12 @@ export const ReactCompareSliderHandle: React.FC<ReactCompareSliderHandleProps> =
};

return (
<div className="__rcs-handle-root" {...props} style={_style}>
<div
{...props}
aria-label={props['aria-label'] || 'Drag to move'}
className={className}
style={_style}
>
<div className="__rcs-handle-line" style={_linesStyle} />
<div className="__rcs-handle-button" style={_buttonStyle}>
<ThisArrow />
Expand Down
2 changes: 1 addition & 1 deletion src/ReactCompareSliderImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { styleFitContainer } from './utils';
/** Props for `ReactCompareSliderImage`. */
export type ReactCompareSliderImageProps = React.ImgHTMLAttributes<HTMLImageElement>;

/** Image with defaults from `styleFitContainer` applied. */
/** `Img` element with defaults from `styleFitContainer` applied. */
export const ReactCompareSliderImage: React.FC<ReactCompareSliderImageProps> = ({
style,
...props
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export { ReactCompareSlider } from './ReactCompareSlider';
export type { ReactCompareSliderProps } from './ReactCompareSlider';

export { ReactCompareSliderHandle } from './ReactCompareSliderHandle';
export type { ReactCompareSliderHandleProps } from './ReactCompareSliderHandle';

export { ReactCompareSliderImage } from './ReactCompareSliderImage';
export type { ReactCompareSliderImageProps } from './ReactCompareSliderImage';

export type { ReactCompareSliderAllProps, ReactCompareSliderProps } from './types';

export { styleFitContainer } from './utils';
24 changes: 24 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,27 @@ export interface ReactCompareSliderCommonProps {
/** Divider position. */
position: ReactCompareSliderPropPosition;
}

/** Comparison slider properties. */
export interface ReactCompareSliderProps extends Partial<ReactCompareSliderCommonProps> {
/** Padding in pixels to limit the slideable bounds on the X-axis (landscape) or Y-axis (portrait). */
boundsPadding?: number;
/** Whether the slider should follow the pointer on hover. */
changePositionOnHover?: boolean;
/** Custom handle component. */
handle?: React.ReactNode;
/** First item to show. */
itemOne: React.ReactNode;
/** Second item to show. */
itemTwo: React.ReactNode;
/** How many pixels to move the handle on keyboard arrow press. */
keyboardMovementIncrement?: number;
/** Whether to only change position when handle is interacted with (useful for touch devices). */
onlyHandleDraggable?: boolean;
/** Callback on position change with position as percentage. */
onPositionChange?: (position: ReactCompareSliderPropPosition) => void;
}

/** `ReactCompareSliderProps` *and* all valid `div` element props. */
export type ReactCompareSliderAllProps = ReactCompareSliderProps &
React.HtmlHTMLAttributes<HTMLDivElement>;
12 changes: 11 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { RefObject, useCallback, useEffect, useLayoutEffect, useRef } from 'react';

/** Keyboard `key` events to trigger slider movement. */
export enum KeyboardEventKeys {
ARROW_LEFT = 'ArrowLeft',
ARROW_RIGHT = 'ArrowRight',
ARROW_UP = 'ArrowUp',
ARROW_DOWN = 'ArrowDown',
}

/**
* Stand-alone CSS utility to make replaced elements (`img`, `video`, etc.) fit their
* container.
Expand Down Expand Up @@ -71,7 +79,9 @@ export const useEventListener = (
* @see <https://github.com/reduxjs/react-redux/blob/c581d480dd675f2645851fb006bef91aeb6ac24d/src/utils/useIsomorphicLayoutEffect.js>
*/
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' && window.document && window.document.createElement
typeof window !== 'undefined' &&
window.document &&
typeof window.document.createElement !== 'undefined'
? useLayoutEffect
: useEffect;

Expand Down

0 comments on commit 8a6efeb

Please sign in to comment.