w3c / w3c/csswg-drafts

[css-ui][content-selection] Attempting to start a selection in an element where `user-select` is `none`, must cause a pre-existing selection to become unselected.

Open
#9,731 0 comments 4 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

css-ui-4
Dominant language
Bikeshed
Stars
4.9k
Forks
816
Avg merge
2d 18h
Merged PRs (30d)
24

Description

Current behavior

Attempting to start a selection in an element where user-select is none, such as by clicking in it or starting a drag in it, must not cause a pre-existing selection to become unselected or to be affected in any way.

View in spec

Expected behavior

New behavior:

Attempting to start a selection in an element where user-select is none, must cause a pre-existing selection to become unselected.

The reasoning

The current behavior results in a bad user experience; Because the user is unable to unselect a selection which is an unexpected behavior. Any attempt to unselect a selection such as text must not be prevented.

Normally, users can deselect any selection by clicking anywhere inside the web page. When user-select: none is used on some elements, they become deadzones that clicking on them does not deselect and results in feeling something is wrong.

Minimal reproduction of the issue

<body style="user-select: none;">
    <p>This text can't be selected and that's expected.</p>
    <pre style="user-select: text;"><code>console.log("This code can be selected but can't be deselected when clicking anywhere outside of it.")</code></pre>
</body>

When you click anywhere outside of pre, the selected code cannot be deselected.

Image

In this whole canvas your try clicking in a white area to deselect but it won't work.

You could also view the attached video example in chromium issue report.

Why user-select: none is useful

It can prevent issues like this in web pages:

Image

This one is a screenshot from an electron desktop app:

Image

So, user-select: none is super useful because it prevents issues like this. In web pages in most have happened to so many peopel where by mistake you get all text content selected. This this annoying, bad UX, and looks cheap and buggy to the user; When I look that above screenshots, it gives me discomfort and feeling of being trapped. Using user-select: none makes the web page feel more like an app that a text document.

Web tools are used for making desktop apps (with tools like electron, etc) and this makes user-select: none more necessary and useful than ever. Using user-select: none makes these apps feel more like a native app than a cheap web document wrapped in a desktop window.

Workaround
Workaround
<style>
    ::selection {
        background: transparent;
    }
    pre ::selection {
        background: blue;
        color: white;
    }
</style>
<p>This text can't be selected and that's expected.</p>
<pre><code>console.log("This code can be selected but can't be deselected when clicking anywhere outside of it.")</code></pre>

Maybe this could be a good workaround for devs and usecases. Content is still selectable but its background is transparent so isn't not visible and not annoying. Note that because the content is still selectable, it may cause issues depending on your usecase. You would loose the default blue color and would need to apply a custom (both) background and color. I have not tested this on a real project so it may not be ideal.

Workaround 2
Workaround 2
/**
 * Clears any active text selection when the user clicks on an element
 * (or any ancestor of it, no depth limit) that has `user-select: none`,
 * UNLESS the click landed on the element that actually contains the
 * selected text (clicking that element or inside it keeps the selection;
 * clicking one of ITS ancestors clears it).
 *
 * Wrapped in an IIFE so the helper functions don't leak onto `window`.
 */
;(() => {
    /**
     * Walk up from `el` and return the first ancestor (inclusive) whose
     * computed style has `user-select: none`. Returns null if none found.
     *
     * Note: `user-select` has shipped unprefixed in every evergreen browser
     * (including Safari, since 15.4) for years, so no vendor-prefix fallback
     * is needed here.
     *
     * @param {Element | null} el
     * @returns {Element | null}
     */
    function getUserSelectNoneAncestor(el) {
        /** @type {Element | null} */
        let current = el

        while (current) {
            if (getComputedStyle(current).userSelect === "none") {
                return current
            }
            current = current.parentElement
        }

        return null
    }

    /**
     * Returns the element(s) that "contain" the current selection, i.e. the
     * common ancestor element of each range in the selection. Most
     * selections only have one range, but Firefox allows multiple disjoint
     * ranges (e.g. via Ctrl+drag), so every range is accounted for.
     *
     * @param {Selection} selection
     * @returns {Element[]}
     */
    function getSelectionContainerElements(selection) {
        /** @type {Element[]} */
        const containers = []

        for (let i = 0; i < selection.rangeCount; i++) {
            const commonAncestor =
                selection.getRangeAt(i).commonAncestorContainer
            const container =
                commonAncestor instanceof Element ? commonAncestor
                :   commonAncestor.parentElement

            if (container) {
                containers.push(container)
            }
        }

        return containers
    }

    /**
     * Resolves the "real" element a click originated from, accounting for
     * shadow DOM. `event.target` gets retargeted to the shadow host once an
     * event crosses a shadow boundary, so `composedPath()[0]` is used to get
     * the actual innermost element instead.
     *
     * @param {MouseEvent} event
     * @returns {Element | null}
     */
    function getClickedElement(event) {
        const path =
            typeof event.composedPath === "function" ?
                event.composedPath()
            :   []
        const innermost = path.length > 0 ? path[0] : event.target
        return innermost instanceof Element ? innermost : null
    }

    /**
     * Click handler: clears the selection when the click lands on a
     * user-select:none ancestor chain that is itself outside the element
     * containing the current selection.
     *
     * Caveat: this listens on `click`, which fires after `mousedown`. Some
     * browsers collapse an existing selection on `mousedown` by default
     * before `click` ever runs, which can make the selection already gone
     * by the time this handler sees it. If that's observed in practice,
     * move this same logic to `mousedown` (calling `event.preventDefault()`
     * on the "keep selection" branch to stop that native collapse).
     *
     * @param {MouseEvent} event
     */
    function handleDocumentClick(event) {
        const selection = window.getSelection()

        // Nothing selected -> nothing to do.
        if (!selection || selection.isCollapsed) {
            return
        }

        const target = getClickedElement(event)
        if (!target) {
            return
        }

        // Click wasn't on (or inside) any element with user-select:none -> ignore.
        if (!getUserSelectNoneAncestor(target)) {
            return
        }

        const selectionContainers =
            getSelectionContainerElements(selection)

        // If the click happened on (or inside) the element that actually
        // contains the selected text, leave the selection alone.
        const clickedInsideSelection = selectionContainers.some(
            (container) =>
                container === target || container.contains(target),
        )
        if (clickedInsideSelection) {
            return
        }

        // Otherwise (click was on an ancestor of the selection container, and
        // that ancestor chain has user-select:none) -> clear the selection.
        selection.removeAllRanges()
    }

    document.addEventListener("click", handleDocumentClick)
})()

Refs

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

The issue points to the CSS UI content-selection section rather than a repository file or test; begin by comparing its normative text with Chromium issue 1267856 and the reproduction. Done means the Working Group resolves the contradictory behavior and updates the specification text consistently; no implementation target is named.

Written by the indexing model from the issue text.

Assessment

Tech stack
css
Domain
documentation
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.