diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 3aed8e1fa58..5f480300ca2 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -758,9 +758,7 @@ export function PromptToken(props: PromptTokenProps) { font: 'ui', backgroundColor: { default: 'transparent-overlay-1000/10', - isSelected: 'blue-800', - // Firefox ignores completely transparent selection colors, so we need to use a nearly transparent color instead - '::selection': '[#ffffff01]' + isSelected: 'blue-800' }, color: { default: 'body', @@ -778,6 +776,7 @@ export function PromptToken(props: PromptTokenProps) { outlineOffset: -1, borderRadius: 'pill', boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`, + boxDecorationBreak: 'clone', paddingX: 8, // not using inline-flex here due to a text selection bug in WebKit. paddingY: space(3), diff --git a/packages/react-aria-components/src/TokenField.tsx b/packages/react-aria-components/src/TokenField.tsx index ddc96c403e3..cd6e60ca42b 100644 --- a/packages/react-aria-components/src/TokenField.tsx +++ b/packages/react-aria-components/src/TokenField.tsx @@ -29,6 +29,7 @@ import {FieldInputContext} from './Autocomplete'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import {HoverProps, useHover} from 'react-aria/useHover'; +import {isDocument, isShadowRoot} from 'react-aria/private/utils/domHelpers'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; @@ -50,6 +51,7 @@ import { useTokenFieldState } from 'react-stately/useTokenFieldState'; import {useFocusRing} from 'react-aria/useFocusRing'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useToken, useTokenField} from 'react-aria/useTokenField'; @@ -271,6 +273,8 @@ export const TokenInput = /*#__PURE__*/ (forwardRef as forwardRefType)(function }); let DOMProps = filterDOMProps(domProps, {global: true}); + let objectRef = useObjectRef(ref); + useLayoutEffect(() => insertSelectionStyle(objectRef), [objectRef]); return ( + {'\u200b'} {token} {'\u200b'} @@ -382,3 +386,40 @@ const CompositionRenderBlocker = memo( (prevProps, nextProps) => nextProps.isComposing ? true : prevProps.children === nextProps.children ); + +// Inserts a stylesheet into the document / shadow root that hides the native text selection on tokens. +function insertSelectionStyle(ref: RefObject | null) { + if (typeof CSSStyleSheet !== 'function' || !ref || !ref.current) { + return; + } + + let root = ref.current.getRootNode(); + if (!isDocument(root) && !isShadowRoot(root)) { + return; + } + + if (!root.adoptedStyleSheets) { + return; + } + + // Skip if we already inserted it. + let sym = Symbol.for('react-aria-token-style'); + if (root.adoptedStyleSheets.some(s => s[sym])) { + return; + } + + let style = new CSSStyleSheet(); + style[sym] = true; + // Firefox ignores completely transparent selection colors, so use a nearly transparent color instead. + style.replaceSync( + '[data-react-aria-token]::selection,[data-react-aria-token]>*::selection{background:#ffffff01}' + ); + root.adoptedStyleSheets.push(style); + + return () => { + let index = root.adoptedStyleSheets.indexOf(style); + if (index >= 0) { + root.adoptedStyleSheets.splice(index, 1); + } + }; +} diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx index a5e0d6b7f7b..cab0f819203 100644 --- a/packages/react-aria-components/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -854,10 +854,7 @@ describeOrSkip('TokenField browser interactions', () => { await navigateCaret(textbox, multiline, {index: 0, offset: 11}); let mod = modKey(); await userEvent.keyboard(`{${mod}>}{Backspace}{/${mod}}`); - // macOS has a delete-to-line-start shortcut (Cmd+Backspace) that removes the line including - // its leading newline. Windows/Linux have no such shortcut: Ctrl+Backspace deletes the - // previous word ("world"), leaving the newline behind. - await waitForFieldText(getValue, isMacPlatform() ? 'hello' : 'hello\n'); + await waitForFieldText(getValue, 'hello\n'); }); it('deletes forward to end of line with line-delete forward shortcut', async () => { diff --git a/packages/react-aria/exports/private/utils/domHelpers.ts b/packages/react-aria/exports/private/utils/domHelpers.ts index 90f31ad7626..156b11a67a4 100644 --- a/packages/react-aria/exports/private/utils/domHelpers.ts +++ b/packages/react-aria/exports/private/utils/domHelpers.ts @@ -2,5 +2,6 @@ export { addEvent, getOwnerDocument, getOwnerWindow, + isDocument, isShadowRoot } from '../../../src/utils/domHelpers'; diff --git a/packages/react-aria/src/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts index 15c9547b5b8..cd2572de546 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -11,6 +11,7 @@ */ import {HTMLAttributes, RefObject, useRef, useState} from 'react'; +import {isVirtualClick} from '../utils/isVirtualEvent'; import {useEvent} from '../utils/useEvent'; export interface TokenProps {} @@ -54,7 +55,24 @@ export function useToken( suppressContentEditableWarning: true, style: { userSelect: 'all', - WebkitUserSelect: 'all' + WebkitUserSelect: 'all', + WebkitTapHighlightColor: 'transparent' + }, + onClick(e) { + // Select the token when a screen reader clicks on it. + if (isSelected || !isVirtualClick(e.nativeEvent)) { + return; + } + let selection = window.getSelection(); + let wrapper = ref.current?.parentElement; + if (!selection || !wrapper) { + return; + } + let range = document.createRange(); + range.setStartBefore(wrapper); + range.setEndAfter(wrapper); + selection.removeAllRanges(); + selection.addRange(range); } }, isSelected diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index ee050095218..1c0cac2ef1e 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -760,19 +760,21 @@ function createDOMRange(root: Element, start: Position, end: Position): Range { } function getDOMPosition(root: Element, pos: Position): [Node, number] { - let child = root.childNodes[pos.index]; + let index = Math.max(0, Math.min(root.childNodes.length, pos.index)); + let child = root.childNodes[index]; if (!child) { - return [root, Math.min(root.childNodes.length, pos.index)]; + return [root, index]; } else if (child.nodeType === Node.ELEMENT_NODE) { // Place the cursor outside the token wrapper element. // This is necessary for composition events. if (pos.offset > 0) { - return [root, pos.index + 1]; + return [root, index + 1]; } else { - return [root, pos.index]; + return [root, index]; } } else { - return [child, pos.offset]; + let offset = Math.max(0, Math.min(child.textContent?.length ?? 0, pos.offset)); + return [child, offset]; } }