Skip to content

Commit

Permalink
next: prevent text selection overflow (#482)
Browse files Browse the repository at this point in the history
  • Loading branch information
anatolzak authored Apr 18, 2024
1 parent 694fe14 commit cdc7a6b
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/bits/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as Root } from "./prevent-text-selection-overflow-layer.svelte";

export type { PreventTextSelectionOverflowLayerProps as Props } from "./types.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import type { PreventTextSelectionOverflowLayerProps } from "./types.js";
import { preventTextSelectionOverflowLayerState } from "./prevent-text-selection-overflow-layer.svelte.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { noop } from "$lib/index.js";
let {
enabled = true,
onPointerDown = noop,
onPointerUp = noop,
id,
children,
}: PreventTextSelectionOverflowLayerProps = $props();
preventTextSelectionOverflowLayerState({
id: readonlyBox(() => id),
enabled: readonlyBox(() => enabled),
onPointerDown: readonlyBox(() => onPointerDown),
onPointerUp: readonlyBox(() => onPointerUp),
});
</script>

{@render children?.()}
Original file line number Diff line number Diff line change
@@ -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<Omit<PreventTextSelectionOverflowLayerProps, "children">>
>;

const layers = new Map<PreventTextSelectionOverflowLayerState, ReadonlyBox<boolean>>();

export class PreventTextSelectionOverflowLayerState {
#onPointerDownProp: ReadonlyBox<EventCallback<PointerEvent>>;
#onPointerUpProp: ReadonlyBox<EventCallback<PointerEvent>>;
#enabled: ReadonlyBox<boolean>;
#unsubSelectionLock = noop;
#node: Box<HTMLElement | null>;

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;
}
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit cdc7a6b

Please sign in to comment.