From cdc7a6b22770fc6fc60e6c445fde3632a1a4cd1c Mon Sep 17 00:00:00 2001 From: Anatol Zakrividoroga <53095479+anatolzak@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:59:17 +0200 Subject: [PATCH] next: prevent text selection overflow (#482) --- packages/bits-ui/src/lib/bits/index.ts | 1 + .../index.ts | 3 + ...event-text-selection-overflow-layer.svelte | 23 +++++ ...nt-text-selection-overflow-layer.svelte.ts | 97 +++++++++++++++++++ .../types.ts | 30 ++++++ 5 files changed, 154 insertions(+) create mode 100644 packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/index.ts create mode 100644 packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte create mode 100644 packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte.ts create mode 100644 packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/types.ts diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index ae53e1b70..3af6da940 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -33,4 +33,5 @@ export * as Toggle from "./toggle/index.js"; export * as ToggleGroup from "./toggle-group/index.js"; export * as Toolbar from "./toolbar/index.js"; export * as Tooltip from "./tooltip/index.js"; +export * as PreventTextSelectionOverflowLayer from "./utilities/prevent-text-selection-overflow-layer/index.js"; export * as DismissableLayer from "./utilities/dismissable-layer/index.js"; diff --git a/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/index.ts b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/index.ts new file mode 100644 index 000000000..f75de8d15 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/index.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./prevent-text-selection-overflow-layer.svelte"; + +export type { PreventTextSelectionOverflowLayerProps as Props } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte new file mode 100644 index 000000000..195e3198f --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte @@ -0,0 +1,23 @@ + + +{@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte.ts new file mode 100644 index 000000000..fcfb8b102 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/prevent-text-selection-overflow-layer.svelte.ts @@ -0,0 +1,97 @@ +import { onDestroy } from "svelte"; +import type { PreventTextSelectionOverflowLayerProps } from "./types.js"; +import type { Box, ReadonlyBox, ReadonlyBoxedValues } from "$lib/internal/box.svelte.js"; +import { useNodeById } from "$lib/internal/elements.svelte.js"; +import { type EventCallback, addEventListener, composeHandlers } from "$lib/internal/events.js"; +import { executeCallbacks, noop } from "$lib/helpers/callbacks.js"; +import { isHTMLElement } from "$lib/internal/is.js"; +import { isOrContainsTarget } from "$lib/helpers/elements.js"; + +type StateProps = ReadonlyBoxedValues< + Required> +>; + +const layers = new Map>(); + +export class PreventTextSelectionOverflowLayerState { + #onPointerDownProp: ReadonlyBox>; + #onPointerUpProp: ReadonlyBox>; + #enabled: ReadonlyBox; + #unsubSelectionLock = noop; + #node: Box; + + constructor(props: StateProps) { + this.#node = useNodeById(props.id); + this.#enabled = props.enabled; + this.#onPointerDownProp = props.onPointerDown; + this.#onPointerUpProp = props.onPointerUp; + + layers.set(this, this.#enabled); + const unsubEvents = this.#addEventListeners(); + + onDestroy(() => { + unsubEvents(); + this.#resetSelectionLock(); + layers.delete(this); + }); + } + + #addEventListeners() { + return executeCallbacks( + addEventListener(document, "pointerdown", this.#pointerdown), + addEventListener( + document, + "pointerup", + composeHandlers(this.#resetSelectionLock, this.#onPointerUpProp) + ) + ); + } + + #pointerdown = (e: PointerEvent) => { + const node = this.#node.value; + const target = e.target; + if (!node || !isHTMLElement(target) || !this.#enabled.value) return; + /** + * We only lock user-selection overflow if layer is the top most layer and + * pointerdown occured inside the node. You are still allowed to select text + * outside the node provided pointerdown occurs outside the node. + */ + if (!isHighestLayer(this) || !isOrContainsTarget(node, target)) return; + this.#onPointerDownProp.value(e); + if (e.defaultPrevented) return; + this.#unsubSelectionLock = preventTextSelectionOverflow(node); + }; + + #resetSelectionLock = () => { + this.#unsubSelectionLock(); + this.#unsubSelectionLock = noop; + }; +} + +export function preventTextSelectionOverflowLayerState(props: StateProps) { + return new PreventTextSelectionOverflowLayerState(props); +} + +const getUserSelect = (node: HTMLElement) => node.style.userSelect || node.style.webkitUserSelect; + +function preventTextSelectionOverflow(node: HTMLElement) { + const body = document.body; + const originalBodyUserSelect = getUserSelect(body); + const originalNodeUserSelect = getUserSelect(node); + setUserSelect(body, "none"); + setUserSelect(node, "text"); + return () => { + setUserSelect(body, originalBodyUserSelect); + setUserSelect(node, originalNodeUserSelect); + }; +} + +function setUserSelect(node: HTMLElement, value: string) { + node.style.userSelect = value; + node.style.webkitUserSelect = value; +} + +function isHighestLayer(instance: PreventTextSelectionOverflowLayerState) { + const [topLayer] = [...layers].at(-1)!; + return topLayer === instance; +} diff --git a/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/types.ts new file mode 100644 index 000000000..0fbac8849 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/prevent-text-selection-overflow-layer/types.ts @@ -0,0 +1,30 @@ +import type { Snippet } from "svelte"; + +export type PointerHandler = (e: PointerEvent) => void; + +export type PreventTextSelectionOverflowLayerProps = { + /** + * DOM ID of the node. + */ + id: string; + + children?: Snippet; + + /** + * Callback fired when pointerdown event triggers. Call `preventDefault()` + * to disable the prevention of text-selection overflow. + */ + onPointerDown?: PointerHandler; + + /** + * Callback fired when pointerup event triggers. + */ + onPointerUp?: PointerHandler; + + /** + * Passing `enabled: true` will prevent the overflow of text selection + * outside the element, provided the element is the top layer. + * @defaultValue `true` + */ + enabled?: boolean; +};