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;
+};