Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Core Changes

- Added new auxiliary magnifier component for mobile users (See [accessibility](/README.md#accessibility-optional))
- Fixed parent's margin not being taken into for account for positioning
- Fixed unnecessary calculations on pointerdown
- Fixed unnecessary forced rerenders on the outlet component
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Import the `useSelectify` hook. Both default and named imports are supported.
```tsx
import { useSelectify } from "use-selectify";
```

## Anatomy

```tsx
Expand Down Expand Up @@ -461,13 +462,13 @@ export function App() {
| selectionDelay | number | 0 | Specify a delay in miliseconds before elements are selected to prevent accidental selection. |
| label | string | "Drag Selection" | Accessible label for screen readers. |
| selectionTolerance | number | 0 | Distance in px from which elements can be selected even if the selection box is not visually intersecting. |
| activateOnMetaKey | boolean | false | Only enables the selection box if the user was pressing a meta key while initiating the drag. Included Meta keys are: Shift, Ctrl/Cmd and Alt. |
| activateOnKey | string[] | [] | Only enables the selection box if the user was pressing a specified key while initiating the drag. Ex: ["Tab", "Control", "Alt"] |
| activateOnMetaKey | boolean | false | Only enables the selection box if the user was pressing a meta key while initiating the drag. Included Meta keys are: Shift, Ctrl/Cmd and Alt. |
| activateOnKey | string[] | [] | Only enables the selection box if the user was pressing a specified key while initiating the drag. Ex: ["Tab", "Control", "Alt"] |
| theme | "default" \| "outline" | "default" | Included theme options for the selection box appearance. |
| hideOnScroll | boolean | false | Whether to hide the selection box when the window starts scrolling. Incompatible with autoScroll. |
| exclusionZone | Element \| Element[] \| string | - | Won't enable the selection box if the user tries initiating the drag from one of the specified elements. |
| scrollContext | HTMLElement \| Window | `window` | Sets the scrollable element for the automatic window scrolling to react. |
| exclusionZone | Element \| Element[] | - | Won't enable the selection box if the user tries initiating the drag from one of the specified elements. Supports CSS Selectors. |
| exclusionZone | Element \| Element[] | - | Won't enable the selection box if the user tries initiating the drag from one of the specified elements. Supports CSS Selectors. |
| lazyLoad | boolean | false | Defers loading the selection box. |
| disabled | boolean | false | Disables the selection box interaction & dragging. |
| forceMount | boolean | false | Forces the mounting of the selection box on initialization. |
Expand Down Expand Up @@ -497,6 +498,34 @@ By default use-selectify already follows [WAI-ARIA](https://www.w3.org/WAI/WCAG2

3. Arrow navigation: Make sure every selectable element can also be selected using the arrow keys.

---

<img align="right" width="243" height="408" src="https://i.imgur.com/lSJlQrq.gif" alt="preview">

[BETA] Additionally you can also enhance your app's mobile experience by using the `SelectionMagnifier` component which can display what is being selected with greater precision without the finger covering elements.

```tsx
import * as React from "react";
import { isMobile } from "react-device-detect";
import { useSelectify } from "use-selectify";

export default function App() {
const selectionContainerRef = React.useRef(null);
const { SelectBoxOutlet, SelectionMagnifier } = useSelectify(selectionContainerRef);

return (
<div ref={selectionContainerRef} style={{ position: "relative" }}>
<SelectionMagnifier disabled={!isMobile}>
<div>Hello World</div>
</SelectionMagnifier>
<SelectBoxOutlet />
</div>
);
}
```

---

## FAQ

### How performant is it?
Expand Down
167 changes: 165 additions & 2 deletions src/useSelectify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,147 @@ function promiseWrapper(promise: { default: React.ComponentType<any> }): Promise
const LazySelectionBox = React.lazy(() => promiseWrapper({ default: SelectionBox }));

/* -------------------------------------------------------------------------------------------------
* Selectify Hook
* SelectionMagnifier
* -----------------------------------------------------------------------------------------------*/

const DEFAULT_SELECT_CRITERIA = "*";
const SELECTION_MAGNIFIER_ROOT_NAME = "SelectionMagnifierRoot";
const DEFAULT_MAGNIFYING_ZOOM = 2;

interface SelectionMagnifierRootProps extends SelectionComponentElement {
parentRef: React.RefObject<HTMLElement | null | undefined>;
selectionBox: BoxBoundingPosition | null;
isDragging: boolean;
forceMount?: true;
}

const magnifierLensStyle: React.CSSProperties = {
position: "absolute",
zIndex: "999",
pointerEvents: "none",
overflow: "hidden",
width: "6rem",
height: "4rem",
} as const;

const magnifierContentStyle: React.CSSProperties = {
position: "absolute",
overflow: "visible",
display: "block",
transformOrigin: "left top",
userSelect: "none",
transform: `scale(${DEFAULT_MAGNIFYING_ZOOM})`,
} as const;

const SELECTION_MAGNIFIER_NAME = "SelectionMagnifierOutlet";

interface SelectionMagnifierProps extends SelectionComponentElement {
disabled?: boolean;
forceMount?: true;
}
const DEFAULT_TOUCH_POINT_TOP_OFFSET = 36;

const SelectionMagnifierRoot = React.forwardRef<HTMLDivElement, SelectionMagnifierRootProps>(
(props: SelectionMagnifierRootProps, forwardedRef) => {
const {
parentRef,
selectionBox,
isDragging,
forceMount,
children,
...selectionMagnifierProps
} = props;
const ref = React.useRef<HTMLDivElement>(null);
const composedRefs = useComposedRefs(forwardedRef, ref);
const canRender = selectionBox !== null && isDragging;

const [lensPosition, setLensPosition] = React.useState<{
left: number;
top: number;
} | null>(null);
const calculateLensPosition = React.useCallback(
() => ({
left:
(selectionBox?.left ?? 0) +
(selectionBox?.width ?? 0) -
(ref.current?.clientWidth ?? 0) / 2,
top:
(selectionBox?.top ?? 0) +
(selectionBox?.height ?? 0) -
(ref.current?.clientHeight ?? 0) -
DEFAULT_TOUCH_POINT_TOP_OFFSET,
}),
[selectionBox?.height, selectionBox?.left, selectionBox?.top, selectionBox?.width]
);

const [magnifiedContentPosition, setMagnifiedContentPosition] = React.useState<{
left: number;
top: number;
} | null>(null);
const calculateMagnifiedContentPosition = React.useCallback(
() => ({
left:
-(lensPosition?.left ?? 0) * DEFAULT_MAGNIFYING_ZOOM -
(parentRef.current?.scrollLeft ?? 0) * DEFAULT_MAGNIFYING_ZOOM -
(ref.current?.clientWidth ?? 0) / 2,
top:
-(lensPosition?.top ?? 0) * DEFAULT_MAGNIFYING_ZOOM -
(parentRef.current?.scrollTop ?? 0) * DEFAULT_MAGNIFYING_ZOOM -
DEFAULT_TOUCH_POINT_TOP_OFFSET * 2 * DEFAULT_MAGNIFYING_ZOOM +
DEFAULT_TOUCH_POINT_TOP_OFFSET,
}),
[lensPosition?.left, lensPosition?.top, parentRef]
);

useIsomorphicLayoutEffect(() => {
// Run just after the set ref phase but before repaint
setLensPosition(calculateLensPosition());
setMagnifiedContentPosition(calculateMagnifiedContentPosition());
}, [calculateLensPosition, calculateMagnifiedContentPosition]);

return (
<>
{children}
{canRender && !forceMount ? (
<div
{...selectionMagnifierProps}
ref={composedRefs}
aria-hidden="true"
style={{
...magnifierLensStyle,
...lensPosition,
...props.style,
}}
>
<div
style={{
...magnifierContentStyle,
...magnifiedContentPosition,
width: parentRef.current?.clientWidth ?? 0,
height: parentRef.current?.clientHeight ?? 0,
}}
>
<div
unselectable="on"
style={{
/** `display: table` ensures our content div will match the size of its children in both
* horizontal and vertical axis. This doesn't account for children with *percentage*
* widths that change.
*/
display: "table",
pointerEvents: "none",
}}
>
{children}
</div>
</div>
</div>
) : null}
</>
);
}
);

SelectionMagnifierRoot.displayName = SELECTION_MAGNIFIER_ROOT_NAME;

export interface UseSelectProps {
/**
Expand Down Expand Up @@ -1010,9 +1147,35 @@ function useSelectify<T extends HTMLElement>(

SelectBoxOutlet.displayName = SELECTION_BOX_NAME;

const SelectionMagnifier = React.forwardRef<HTMLDivElement, SelectionMagnifierProps>(
(props: SelectionMagnifierProps, forwardedRef) => {
const { disabled, forceMount, children, ...selectionMagnifierProps } = props;

if (disabled) {
return null;
}

return (
<SelectionMagnifierRoot
{...selectionMagnifierProps}
ref={forwardedRef}
parentRef={ref}
selectionBox={selectionBox}
isDragging={isActive}
forceMount={forceMount}
>
{children}
</SelectionMagnifierRoot>
);
}
);

SelectionMagnifier.displayName = SELECTION_MAGNIFIER_NAME;

return {
SelectBoxOutlet,
selectedElements,
SelectionMagnifier,
isDragging: isActive,
hasSelected,
selectionBox,
Expand Down