Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/@react-spectrum/ai/src/PromptField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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),
Expand Down
45 changes: 43 additions & 2 deletions packages/react-aria-components/src/TokenField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 (
<dom.div
Expand All @@ -282,7 +286,7 @@ export const TokenInput = /*#__PURE__*/ (forwardRef as forwardRefType)(function
tokenFieldProps,
autocompleteProps
)}
ref={ref}
ref={objectRef}
data-focused={isFocused || undefined}
data-focus-visible={isFocusVisible || undefined}
data-disabled={isDisabled || undefined}
Expand All @@ -295,7 +299,7 @@ export const TokenInput = /*#__PURE__*/ (forwardRef as forwardRefType)(function
let token = children(v);
return (
// Wrap tokens in zero-width spaces so the cursor is placed correctly.
<span key={i}>
<span key={i} data-react-aria-token>
{'\u200b'}
{token}
{'\u200b'}
Expand Down Expand Up @@ -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<HTMLDivElement | null> | 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);
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IME fix #10422 broke this test on macOS. getTargetRanges includes a range when deleting to the beginning of the line that does not include the newline. Now that we are using that range instead of computing it ourselves, we get this difference.

});

it('deletes forward to end of line with line-delete forward shortcut', async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-aria/exports/private/utils/domHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export {
addEvent,
getOwnerDocument,
getOwnerWindow,
isDocument,
isShadowRoot
} from '../../../src/utils/domHelpers';
20 changes: 19 additions & 1 deletion packages/react-aria/src/tokenfield/useToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import {HTMLAttributes, RefObject, useRef, useState} from 'react';
import {isVirtualClick} from '../utils/isVirtualEvent';
import {useEvent} from '../utils/useEvent';

export interface TokenProps {}
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions packages/react-aria/src/tokenfield/useTokenField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}

Expand Down