From 439a74607fb6357df7886a535381059bb9b949c4 Mon Sep 17 00:00:00 2001 From: Rortan Date: Sun, 26 Mar 2023 01:59:12 +0100 Subject: [PATCH] feat(experimental): selection magnifier component for better mobile ux --- CHANGELOG.md | 1 + README.md | 35 ++++++++- src/useSelectify.tsx | 167 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 197 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8622a30..67920c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 366f05d..9877718 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Import the `useSelectify` hook. Both default and named imports are supported. ```tsx import { useSelectify } from "use-selectify"; ``` + ## Anatomy ```tsx @@ -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. | @@ -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. +--- + +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 ( +
+ +
Hello World
+
+ +
+ ); +} +``` + +--- + ## FAQ ### How performant is it? diff --git a/src/useSelectify.tsx b/src/useSelectify.tsx index ef6904e..7c9c2d3 100644 --- a/src/useSelectify.tsx +++ b/src/useSelectify.tsx @@ -165,10 +165,147 @@ function promiseWrapper(promise: { default: React.ComponentType }): 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; + 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( + (props: SelectionMagnifierRootProps, forwardedRef) => { + const { + parentRef, + selectionBox, + isDragging, + forceMount, + children, + ...selectionMagnifierProps + } = props; + const ref = React.useRef(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 ? ( + + ) : null} + + ); + } +); + +SelectionMagnifierRoot.displayName = SELECTION_MAGNIFIER_ROOT_NAME; /* ------------------------------------------------------------------------------------------------- * Selectify Hook @@ -1016,11 +1153,35 @@ function useSelectify( SelectBoxOutlet.displayName = SELECTION_BOX_NAME; - SelectBoxOutlet.displayName = SELECT_BOX_NAME; + const SelectionMagnifier = React.forwardRef( + (props: SelectionMagnifierProps, forwardedRef) => { + const { disabled, forceMount, children, ...selectionMagnifierProps } = props; + + if (disabled) { + return null; + } + + return ( + + {children} + + ); + } + ); + + SelectionMagnifier.displayName = SELECTION_MAGNIFIER_NAME; return { SelectBoxOutlet, selectedElements, + SelectionMagnifier, isDragging: isActive, hasSelected, selectionBox,