diff --git a/.circleci/config.yml b/.circleci/config.yml index 8cc283664a7..9c118a4cff4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -411,6 +411,19 @@ jobs: node --loader ./scripts/esm-support/loader.mjs ./scripts/esm-support/testESM.mjs node scripts/testCJS.cjs + test-browser: + docker: + - image: mcr.microsoft.com/playwright:v1.60.0-noble + working_directory: /home/circleci/react-spectrum + steps: + - restore_cache: + key: react-spectrum-{{ .Environment.CACHE_VERSION }}-{{ .Environment.CIRCLE_SHA1 }} + + - run: + name: test browser + command: | + yarn test:browser + lint: executor: rsp-xlarge steps: @@ -973,6 +986,9 @@ workflows: - test-build: requires: - install + - test-browser: + requires: + - install - lint: requires: - install diff --git a/packages/@react-spectrum/ai/src/TokenField.tsx b/packages/@react-spectrum/ai/src/TokenField.tsx index df659987a46..7563d992599 100644 --- a/packages/@react-spectrum/ai/src/TokenField.tsx +++ b/packages/@react-spectrum/ai/src/TokenField.tsx @@ -20,11 +20,22 @@ import { } from './TokenSegmentList'; import {FieldInputContext} from 'react-aria-components/Autocomplete'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {FocusableProps} from '@react-types/shared'; +import {FocusableProps, forwardRefType} from '@react-types/shared'; +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {getOwnerDocument} from 'react-aria/private/utils/domHelpers'; import {isMac} from 'react-aria/private/utils/platform'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {ForwardedRef, forwardRef, HTMLAttributes, useMemo, useRef, useState} from 'react'; +import React, { + ForwardedRef, + forwardRef, + HTMLAttributes, + memo, + useCallback, + useMemo, + useRef, + useState +} from 'react'; import {RenderProps, StyleRenderProps, useRenderProps} from 'react-aria-components/useRenderProps'; import {SlotProps, useSlottedContext} from 'react-aria-components/slots'; import {useControlledState} from 'react-stately/useControlledState'; @@ -45,12 +56,14 @@ interface TokenFieldRenderProps { isFocusVisible: boolean; } -export interface TokenFieldProps +export interface TokenFieldProps extends StyleRenderProps, SlotProps, FocusableProps { - value?: TokenSegmentList; - defaultValue?: TokenSegmentList; - onChange?: (value: TokenSegmentList) => void; - children: (segment: TokenSegment) => React.ReactElement; + value?: T; + defaultValue?: T; + onChange?: (value: T) => void; + children: ( + segment: TokenSegment ? V : never> + ) => React.ReactElement; multiline?: boolean; isReadOnly?: boolean; isDisabled?: boolean; @@ -63,10 +76,9 @@ export interface TokenFieldProps export const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; -export const TokenField = forwardRef(function TokenField( - props: TokenFieldProps, - forwardedRef: ForwardedRef -) { +export const TokenField = /*#__PURE__*/ (forwardRef as forwardRefType)(function TokenField< + T extends TokenSegmentList = TokenSegmentList +>(props: TokenFieldProps, forwardedRef: ForwardedRef) { let { value: valueProp, defaultValue: defaultValueProp = new TokenSegmentList([]), @@ -100,24 +112,86 @@ export const TokenField = forwardRef(function TokenField( let dropPosition = useRef(null); let transferredData = useRef(null); + let nextValue = useRef(null); let apply = (fn: (value: TokenSegmentList) => TokenSegmentList) => { setState(value => { let newValue = fn(value); + nextValue.current = newValue; onAutocompleteChange?.(newValue.toString()); return newValue; }); }; + // Composition events are not cancelable. The browser will mutate the DOM, making it out of sync with React. + // To account for this, we prevent React from re-rendering during composition, and track DOM mutations performed + // by the browser. When composition ends, we revert the DOM to its original state, and re-render with React. + // Mutating the DOM in any way during composition breaks the IME, causing composition to end unexpectedly. + // During composition, we still emit updates via onChange to ensure that things like autocomplete work, + // but we don't actually re-render to the DOM unless the value changes from what we expect (e.g. inserting a completion). + let [isComposing, setComposing] = useState(false); + let mutationTracker = useMutationTracker(ref); + let startComposition = useCallback(() => { + mutationTracker.start(); + setComposing(true); + }, [mutationTracker]); + let stopComposition = useCallback(() => { + mutationTracker.stop(); + setComposing(false); + }, [mutationTracker]); + + useEvent(ref, 'compositionstart', () => { + startComposition(); + + let range = window.getSelection()?.getRangeAt(0); + if (range) { + let [start, end] = rangeToPositions(ref.current!, range); + + // Normalize the range to ensure it is not inside a token, otherwise the browser + // will attempt to insert the composed text into the token instead of replacing it. + let r = createDOMRange(ref.current!, start, end); + if (r.startContainer !== range.startContainer || r.startOffset !== range.startOffset) { + range.setStart(r.startContainer, r.startOffset); + } + if (r.endContainer !== range.endContainer || r.endOffset !== range.endOffset) { + range.setEnd(r.endContainer, r.endOffset); + } + } + }); + + useEvent(ref, 'compositionend', stopComposition); + + // If a prop update occurs during composition that doesn't match the expected value, + // end composition and re-render the controlled value. + useLayoutEffect(() => { + if (isComposing && state !== nextValue.current) { + stopComposition(); + } + nextValue.current = state; + }); + let caretPosition = useRef(null); useLayoutEffect(() => { - if (ref.current && state.caretPosition && state.caretPosition !== caretPosition.current) { - setCursor(ref.current, state.caretPosition); + if ( + ref.current && + state.caretPosition && + !isComposing && + state.caretPosition !== caretPosition.current + ) { + // Only move the caret when the field is already focused. + if (ref.current === getActiveElement(getOwnerDocument(ref.current))) { + setCursor(ref.current, state.caretPosition); + } caretPosition.current = state.caretPosition; } }); // Handle text editing commands and prevent browser default behavior. useEvent(ref, 'beforeinput', e => { + // Android sometimes doesn't fire a compositionend event before a regular input event. + if (isComposing && !e.isComposing) { + stopComposition(); + } + let selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { return; @@ -129,6 +203,8 @@ export const TokenField = forwardRef(function TokenField( switch (e.inputType) { case 'insertText': case 'insertReplacementText': + case 'insertCompositionText': + case 'insertFromComposition': // Removed from the spec, but still fired by Safari. case 'insertFromPaste': case 'insertFromYank': case 'insertFromDrop': { @@ -137,8 +213,11 @@ export const TokenField = forwardRef(function TokenField( data = transferredData.current; transferredData.current = null; } else if (e.dataTransfer) { - if (e.dataTransfer.types.includes(CLIPBOARD_MIME_TYPE)) { - data = JSON.parse(e.dataTransfer.getData(CLIPBOARD_MIME_TYPE)); + let parsed = e.dataTransfer.types.includes(CLIPBOARD_MIME_TYPE) + ? parseSegments(e.dataTransfer.getData(CLIPBOARD_MIME_TYPE)) + : null; + if (parsed) { + data = parsed; } else if (e.dataTransfer.types.includes('text/plain')) { data[0].text = e.dataTransfer.getData('text/plain'); } @@ -149,12 +228,21 @@ export const TokenField = forwardRef(function TokenField( dropPosition.current = null; } + if (!multiline) { + for (let segment of data) { + segment.text = segment.text.replace(/[\r\n]+/g, ' '); + } + } + apply(tokens => tokens.replaceRangeWithSegments( start, end, data, - e.inputType === 'insertText' // Don't coalesce paste/drop events with other edits. + // Don't coalesce paste/drop events with other edits. + e.inputType === 'insertText' || + e.inputType === 'insertCompositionText' || + e.inputType === 'insertFromComposition' ) ); break; @@ -181,7 +269,8 @@ export const TokenField = forwardRef(function TokenField( case 'deleteSoftLineForward': case 'deleteSoftLineBackward': case 'deleteContent': - case 'deleteByCut': { + case 'deleteByCut': + case 'deleteCompositionText': { if (!range.collapsed) { apply(tokens => tokens.replaceRange(start, end, '')); break; @@ -235,24 +324,6 @@ export const TokenField = forwardRef(function TokenField( e.preventDefault(); }); - // Composition events are not cancelable, so we need to store the start position and update the value in the compositionend event. - let compositionStart = useRef<[Position, Position] | null>(null); - useEvent(ref, 'compositionstart', () => { - let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - return; - } - let range = selection.getRangeAt(0); - compositionStart.current = rangeToPositions(ref.current!, range); - }); - - useEvent(ref, 'compositionend', e => { - let range = compositionStart.current; - if (range) { - apply(tokens => tokens.replaceRange(range[0], range[1], e.data)); - } - }); - let writeClipboardData = (e: ClipboardEvent | DragEvent) => { if ('clipboardData' in e) { e.preventDefault(); @@ -278,7 +349,7 @@ export const TokenField = forwardRef(function TokenField( useEvent(ref, 'paste', e => { // Safari doesn't pass the custom clipboard data type to beforeinput dataTransfer so we handle it here. if (e.clipboardData && e.clipboardData.types.includes(CLIPBOARD_MIME_TYPE)) { - transferredData.current = JSON.parse(e.clipboardData.getData(CLIPBOARD_MIME_TYPE)); + transferredData.current = parseSegments(e.clipboardData.getData(CLIPBOARD_MIME_TYPE)); } }); @@ -297,31 +368,33 @@ export const TokenField = forwardRef(function TokenField( } if (e.dataTransfer && e.dataTransfer.types.includes(CLIPBOARD_MIME_TYPE)) { - transferredData.current = JSON.parse(e.dataTransfer.getData(CLIPBOARD_MIME_TYPE)); + transferredData.current = parseSegments(e.dataTransfer.getData(CLIPBOARD_MIME_TYPE)); } }); useSelectionChange(ref, () => { + if (isComposing) { + return; + } + state.endCoalescing(); // When the cursor moves next to a token, announce it. // Otherwise the screen reader will only announce the first/last character. if (window.getSelection()?.isCollapsed) { let [start, end] = getSelection(ref.current!)!; - if (start.index === end.index && start.offset === 0) { + if (start.offset === 0) { let segment = state.segments[start.index]; - if (segment?.type === 'token') { - announce(segment.text, 'assertive'); + if (segment?.type !== 'token') { + segment = state.segments[start.index - 1]; } - } else if (start.offset === state.segments[start.index].text.length) { - let segment = state.segments[start.index + 1]; if (segment?.type === 'token') { announce(segment.text, 'assertive'); } - } - // Update the caret position in the value. - setState(value => value.withCaretPosition(end)); + // Update the caret position in the value. + setState(value => value.withCaretPosition(end)); + } } }); @@ -343,60 +416,78 @@ export const TokenField = forwardRef(function TokenField( } }); - // Firefox does not allow placing the cursor between adjacent tokens, so navigate manually. - let moveSelection = (segmenter: Intl.Segmenter, direction: Direction, extend = false) => { - let selection = getSelection(ref.current!); - if (!selection) { + let moveSelection = ( + direction: 'left' | 'right', + granularity: 'character' | 'word', + extend = false + ) => { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !selection.focusNode || !selection.anchorNode) { + return false; + } + + // Pressing an arrow with a non-empty selection collapses it to the corresponding edge. + // The browser handles this natively. + if (!extend && !selection.isCollapsed) { return false; } - let originalPos = direction === Direction.Backward ? selection[0] : selection[1]; - let pos = - extend || isCollapsed(selection[0], selection[1]) - ? state.findBoundaryWithSegmenter(originalPos, segmenter, direction) - : originalPos; - if (pos) { - let [start, end] = - direction === Direction.Backward - ? [pos, extend ? selection[1] : pos] - : [extend ? selection[0] : pos, pos]; - setSelection(ref.current!, start, end, true); - return true; - } - return false; + + // Move the caret using the browser's native caret movement (Selection.modify) so that + // bidirectional text is handled correctly. Repeat until the position actually changes + // to account for the zero width spaces around tokens. + let pos = getPosition(ref.current!, selection.focusNode, selection.focusOffset); + while (true) { + let {focusNode, focusOffset} = selection; + selection.modify(extend ? 'extend' : 'move', direction, granularity); + if (selection.focusNode === focusNode && selection.focusOffset === focusOffset) { + return false; + } + let newPos = getPosition(ref.current!, selection.focusNode, selection.focusOffset); + if (!isSamePosition(pos, newPos)) { + return true; + } + } }; let mod = isMac() ? 'Meta' : 'Control'; let wordModKey = isMac() ? 'Alt' : 'Control'; let shortcuts: Record boolean | void> = { [`${mod}+z`]: () => { + // If composing, the browser handles undo natively. + if (isComposing) { + return false; + } apply(state => state.undo()); }, [isMac() ? 'Shift+Meta+z' : 'Control+y']: () => { + if (isComposing) { + return false; + } apply(state => state.redo()); }, ArrowLeft: () => { - return moveSelection(graphemeSegmenter, Direction.Backward); + return moveSelection('left', 'character'); }, [`${wordModKey}+ArrowLeft`]: () => { - return moveSelection(wordSegmenter, Direction.Backward); + return moveSelection('left', 'word'); }, 'Shift+ArrowLeft': () => { - return moveSelection(graphemeSegmenter, Direction.Backward, true); + return moveSelection('left', 'character', true); }, [`Shift+${wordModKey}+ArrowLeft`]: () => { - return moveSelection(wordSegmenter, Direction.Backward, true); + return moveSelection('left', 'word', true); }, ArrowRight: () => { - return moveSelection(graphemeSegmenter, Direction.Forward); + return moveSelection('right', 'character'); }, [`${wordModKey}+ArrowRight`]: () => { - return moveSelection(wordSegmenter, Direction.Forward); + return moveSelection('right', 'word'); }, 'Shift+ArrowRight': () => { - return moveSelection(graphemeSegmenter, Direction.Forward, true); + return moveSelection('right', 'character', true); }, [`Shift+${wordModKey}+ArrowRight`]: () => { - return moveSelection(wordSegmenter, Direction.Forward, true); + return moveSelection('right', 'word', true); }, Home: () => { // Browsers do not behave consistently when there are tokens. @@ -426,6 +517,7 @@ export const TokenField = forwardRef(function TokenField( }; let {keyboardProps} = useKeyboard({ + isDisabled: isDisabled || isReadOnly, onKeyDown: e => { // mini version of useKeyboard shortcuts until it is merged. let modifiers = ['Shift', 'Control', 'Alt', 'Meta'] satisfies React.ModifierKey[]; @@ -473,37 +565,41 @@ export const TokenField = forwardRef(function TokenField( {onPaste: props.onPaste} )} ref={mergeRefs(ref, autocompleteRef as any)} - role="textbox" - contentEditable="true" + role={autocompleteProps['role'] || 'textbox'} + contentEditable={!isDisabled && !isReadOnly} suppressContentEditableWarning aria-multiline={multiline} - aria-label={ariaLabel} - aria-labelledby={ariaLabelledBy} - aria-describedby={ariaDescribedBy} + aria-label={ariaLabel ?? autocompleteProps['aria-label']} + aria-labelledby={ariaLabelledBy ?? autocompleteProps['aria-labelledby']} + aria-describedby={ariaDescribedBy ?? autocompleteProps['aria-describedby']} + aria-readonly={isReadOnly || undefined} + aria-disabled={isDisabled || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-disabled={isDisabled || undefined} data-readonly={isReadOnly || undefined} style={{...renderProps.style, whiteSpace: 'pre-wrap'}}> - {state.segments.map((v, i) => { - switch (v.type) { - case 'token': { - let token = children(v); - return ( - // Wrap tokens in zero-width spaces so the cursor is placed correctly. - - {'\u200b'} - {token} - {'\u200b'} - - ); + + {state.segments.map((v, i) => { + switch (v.type) { + case 'token': { + let token = children(v); + return ( + // Wrap tokens in zero-width spaces so the cursor is placed correctly. + + {'\u200b'} + {token} + {'\u200b'} + + ); + } + case 'text': + return v.text; } - case 'text': - return v.text; - } - })} - {/* Force cursor to the next line if the last segment ends with a newline. */} - {state.segments.at(-1)?.text.endsWith('\n') &&
} + })} + {/* Force cursor to the next line if the last segment ends with a newline. */} + {state.segments.at(-1)?.text.endsWith('\n') &&
} +
); }); @@ -603,23 +699,38 @@ function getPosition(container: Element, node: Node, offset: number): Position { let index = indexOfNode(node); if (node.nodeType === Node.ELEMENT_NODE) { let tokenNode = node.childNodes[1]; + let atEnd: boolean; + let endOffset = 0; if (originalNode === tokenNode) { // Cursor is inside the token. - offset = offset > 0 ? (tokenNode?.textContent?.length ?? 0) : 0; + atEnd = offset > 0; } else if (originalNode === node) { // Cursor is inside the wrapper element. - offset = offset <= 1 ? 0 : (tokenNode?.textContent?.length ?? 0); + atEnd = offset > 1; } else { // Cursor is on one of the zero width spaces. - offset = - originalNode === tokenNode.previousSibling ? 0 : (tokenNode?.textContent?.length ?? 0); + atEnd = originalNode !== tokenNode.previousSibling; + // If the offset is greater than 1, the browser is trying to insert text into + // the zero width space node. This will actually end up in the next text node. + endOffset = atEnd && offset > 1 ? offset - 1 : 0; + } + + offset = atEnd ? (tokenNode?.textContent?.length ?? 0) : 0; + + // Several positions are equivalent due to the zero width spaces around tokens. + // Normalize offset to the end of the preceding text node, or the beginning of the following node. + if (offset === 0 && node.previousSibling?.nodeType === Node.TEXT_NODE) { + index--; + offset = node.previousSibling?.textContent?.length ?? 0; + } else if (atEnd) { + index++; + offset = endOffset; } - return {index, offset}; } return {index, offset}; } -let isProgrammaticSelectionChange = false; +let isProgrammaticSelectionChange = Symbol('isProgrammaticSelectionChange'); // TODO: do we want to export these? export function setCursor(root: Element, pos: Position, fireEvent = false) { @@ -630,7 +741,7 @@ export function setSelection(root: Element, start: Position, end: Position, fire let selection = window.getSelection(); if (selection) { let range = createDOMRange(root, start, end); - isProgrammaticSelectionChange = !fireEvent; + root[isProgrammaticSelectionChange] = !fireEvent; selection.removeAllRanges(); selection.addRange(range); } @@ -642,31 +753,68 @@ export function positionToDOMRange(root: Element, pos: Position): Range { function createDOMRange(root: Element, start: Position, end: Position): Range { let range = document.createRange(); - let child = root.childNodes[start.index]; - if (!child) { + let startChild = root.childNodes[start.index]; + if (!startChild) { range.setStart(root, Math.min(root.childNodes.length, start.index)); - } else if (child.nodeType === Node.ELEMENT_NODE) { - // Place the cursor in one of the zero width space nodes. - range.setStart(child, start.offset > 0 ? 2 : 0); + } else if (startChild.nodeType === Node.ELEMENT_NODE) { + // Place the cursor outside the token wrapper element. + if (start.offset > 0) { + range.setStartAfter(startChild); + } else { + range.setStartBefore(startChild); + } } else { - // Place the cursor in the text node. - range.setStart(child, start.offset); + range.setStart(startChild, start.offset); } - child = root.childNodes[end.index]; - if (!child) { + + let endChild = root.childNodes[end.index]; + if (!endChild) { range.setEnd(root, Math.min(root.childNodes.length, end.index)); - } else if (child.nodeType === Node.ELEMENT_NODE) { - range.setEnd(child, end.offset > 0 ? 2 : 0); + } else if (endChild.nodeType === Node.ELEMENT_NODE) { + if (end.offset > 0) { + range.setEndAfter(endChild); + } else { + range.setEndBefore(endChild); + } } else { - range.setEnd(child, end.offset); + range.setEnd(endChild, end.offset); } return range; } +function isSamePosition(a: Position, b: Position): boolean { + return a.index === b.index && a.offset === b.offset; +} + +// Parse and validate segments from clipboard/drag data. Returns null if the data is not valid +// JSON or does not match the expected shape, so malformed or untrusted data is ignored rather +// than throwing or being inserted into the field. +function parseSegments(json: string): TokenFieldSegment[] | null { + try { + let data = JSON.parse(json); + if (Array.isArray(data) && data.length > 0 && data.every(isValidSegment)) { + return data; + } + } catch { + // Ignore invalid clipboard data. + } + return null; +} + +function isValidSegment(segment: unknown): segment is TokenFieldSegment { + return ( + typeof segment === 'object' && + segment != null && + ((segment as TokenFieldSegment).type === 'text' || + (segment as TokenFieldSegment).type === 'token') && + typeof (segment as TokenFieldSegment).text === 'string' + ); +} + function useSelectionChange(ref: React.RefObject, handler: () => void) { useEvent(useRef(document), 'selectionchange', () => { - if (isProgrammaticSelectionChange) { - isProgrammaticSelectionChange = false; + if (ref.current && ref.current[isProgrammaticSelectionChange]) { + ref.current[isProgrammaticSelectionChange] = false; return; } @@ -682,6 +830,74 @@ function useSelectionChange(ref: React.RefObject, handler: () => }); } -function isCollapsed(pos1: Position, pos2: Position) { - return pos1.index === pos2.index && pos1.offset === pos2.offset; +function useMutationTracker(ref: React.RefObject) { + let mutationTracker = useRef<(() => void) | null>(null); + + // Disconnect the mutation observer if the field unmounts mid-composition. + useLayoutEffect( + () => () => { + mutationTracker.current?.(); + mutationTracker.current = null; + }, + [] + ); + + return useMemo( + () => ({ + start() { + // Android sometimes fires two compositionstart events in a row, without a compositionend. + // In that case, reuse the existing tracker. + mutationTracker.current ||= trackMutations(ref.current!); + }, + stop() { + mutationTracker.current?.(); + mutationTracker.current = null; + } + }), + [] + ); +} + +// Tracks mutations to the DOM until the returned function is called, +// at which point the mutations are reverted. +function trackMutations(element: Element) { + let mutations: MutationRecord[] = []; + let observer = new MutationObserver(records => { + mutations.push(...records); + }); + + observer.observe(element, { + childList: true, + subtree: true, + characterData: true, + characterDataOldValue: true + }); + + return () => { + mutations.push(...observer.takeRecords()); + observer.disconnect(); + + for (let record of mutations.reverse()) { + switch (record.type) { + case 'childList': + for (let node of record.removedNodes) { + record.target.insertBefore(node, record.nextSibling); + } + for (let node of record.addedNodes) { + record.target.removeChild(node); + } + break; + case 'characterData': + record.target.nodeValue = record.oldValue; + break; + } + } + }; } + +// Prevents React from re-rendering during composition events. +const CompositionRenderBlocker = memo( + ({children}: {children: React.ReactNode; isComposing: boolean}) => children, + (prevProps, nextProps) => + nextProps.isComposing ? true : prevProps.children === nextProps.children +); diff --git a/packages/@react-spectrum/ai/src/TokenSegmentList.ts b/packages/@react-spectrum/ai/src/TokenSegmentList.ts index 5b8518dcd2c..324fae69297 100644 --- a/packages/@react-spectrum/ai/src/TokenSegmentList.ts +++ b/packages/@react-spectrum/ai/src/TokenSegmentList.ts @@ -10,18 +10,18 @@ * governing permissions and limitations under the License. */ -export type TokenFieldSegment = TextSegment | TokenSegment; +export type TokenFieldSegment = TextSegment | TokenSegment; export interface TextSegment { type: 'text'; text: string; } -export interface TokenSegment { +export interface TokenSegment { type: 'token'; text: string; /** An arbitrary value associated with the token. */ - value?: any; + value?: T; } export interface Position { @@ -43,28 +43,28 @@ export interface TokenSegmentListOptions { /** * A list of segments containing editable text and non-editable tokens. */ -export class TokenSegmentList { - readonly segments: readonly TokenFieldSegment[]; +export class TokenSegmentList { + readonly segments: readonly TokenFieldSegment[]; caretPosition: Position = {index: 0, offset: 0}; // Linked list representing the undo/redo history. - private previous: TokenSegmentList | null = null; - private next: TokenSegmentList | null = null; + private previous: this | null = null; + private next: this | null = null; private isCoalescing = true; - constructor(tokens: readonly TokenFieldSegment[], options?: TokenSegmentListOptions) { + constructor(tokens: readonly TokenFieldSegment[], options?: TokenSegmentListOptions) { this.segments = tokens; this.caretPosition = options?.caretPosition ?? {index: 0, offset: 0}; } - protected createSegmentList(segments: readonly TokenFieldSegment[]): TokenSegmentList { + protected createSegmentList(segments: readonly TokenFieldSegment[]): this { const Constructor = this.constructor as new ( - segments: readonly TokenFieldSegment[], + segments: readonly TokenFieldSegment[], options?: TokenSegmentListOptions - ) => TokenSegmentList; + ) => this; return new Constructor(segments); } - withCaretPosition(caretPosition: Position): TokenSegmentList { + withCaretPosition(caretPosition: Position): this { if ( this.caretPosition.index === caretPosition.index && this.caretPosition.offset === caretPosition.offset @@ -103,7 +103,7 @@ export class TokenSegmentList { return {type: 'text', text}; } - protected tokenize(text: string): TokenFieldSegment[] { + protected tokenize(text: string): TokenFieldSegment[] { return [this.createTextSegment(text)]; } @@ -122,7 +122,7 @@ export class TokenSegmentList { } /** Replace the text between two positions with new text. */ - replaceRange(start: Position, end: Position, text: string, coalesce = true): TokenSegmentList { + replaceRange(start: Position, end: Position, text: string, coalesce = true): this { return this.replaceRangeWithSegments( start, end, @@ -135,9 +135,9 @@ export class TokenSegmentList { replaceRangeWithSegments( start: Position, end: Position, - insert: TokenFieldSegment[], + insert: TokenFieldSegment[], coalesce = true - ): TokenSegmentList { + ): this { start = this.clampPosition(start); end = this.clampPosition(end); let startSegment = this.segments[start.index]; @@ -303,7 +303,7 @@ export class TokenSegmentList { segmenter: Intl.Segmenter, direction: Direction, coalesce = true - ): TokenSegmentList { + ): this { let boundary = this.findBoundaryWithSegmenter(position, segmenter, direction); if (boundary) { return this.replaceRange( @@ -319,7 +319,7 @@ export class TokenSegmentList { } /** Delete text to the next or previous line break. */ - deleteLine(position: Position, direction: Direction, coalesce = true): TokenSegmentList { + deleteLine(position: Position, direction: Direction, coalesce = true): this { if (this.segments.length === 0) { return this; } @@ -337,24 +337,8 @@ export class TokenSegmentList { return this; } - /** Converts the text at a position into a token. */ - insertToken(position: Position): TokenSegmentList { - let segment = this.segments[position.index]; - if (segment && segment.type === 'text') { - return this.replaceRangeWithSegments( - {index: position.index, offset: 0}, - {index: position.index, offset: segment.text.length}, - [{type: 'token', text: segment.text}], - false - ); - } - - this.caretPosition = position; - return this; - } - /** Create a new list containing a subset of the segments. */ - slice(start: Position, end: Position): TokenSegmentList { + slice(start: Position, end: Position): this { start = this.clampPosition(start); end = this.clampPosition(end); if (start.index === end.index && start.offset === end.offset) { @@ -374,7 +358,7 @@ export class TokenSegmentList { let [, startSplit] = this.splitSegment(startSegment, start.offset); let [endSplit] = this.splitSegment(endSegment, end.offset); - let result: TokenFieldSegment[] = []; + let result: TokenFieldSegment[] = []; if (startSplit) { result.push(startSplit); } @@ -389,11 +373,11 @@ export class TokenSegmentList { return this.segments.map(seg => seg.text).join(''); } - undo(): TokenSegmentList { + undo(): this { return this.previous ?? this; } - redo(): TokenSegmentList { + redo(): this { return this.next ?? this; } diff --git a/packages/@react-spectrum/ai/stories/TokenField.stories.tsx b/packages/@react-spectrum/ai/stories/TokenField.stories.tsx index 53398386050..75fa0cebc63 100644 --- a/packages/@react-spectrum/ai/stories/TokenField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/TokenField.stories.tsx @@ -12,21 +12,20 @@ import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Meta, StoryFn} from '@storybook/react'; -import React, {useContext, useMemo, useRef, useState} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import './styles.global.css'; import {Autocomplete} from 'react-aria-components/Autocomplete'; import {ChevronDown} from 'lucide-react'; -import {Collection, ComboBox, ComboBoxStateContext} from 'react-aria-components'; +import {Collection, ComboBox} from 'react-aria-components'; import {ComboBoxItem, ComboBoxListBox} from 'vanilla-starter/ComboBox'; import {Direction, type TokenFieldSegment, TokenSegmentList} from '../src/TokenSegmentList'; import {FieldButton, Label} from 'vanilla-starter/Form'; import {Header, Menu, MenuItem, MenuSection} from 'vanilla-starter/Menu'; -import {InputContext} from 'react-aria-components/Input'; +import {Key} from '@react-types/shared'; import {Popover} from 'vanilla-starter/Popover'; import {positionToDOMRange, Token, TokenField} from '../src/TokenField'; import 'vanilla-starter/TagGroup.css'; import {Text} from 'react-aria-components/Text'; -import {useSlottedContext} from 'react-aria-components/slots'; const events = ['onChange', 'onPaste', 'onSubmit', 'onFocus', 'onBlur', 'onFocusChange']; @@ -57,8 +56,12 @@ class TokenizingSegmentList extends TokenSegmentList { return new this(segments, tokenRegex); } - createSegmentList(segments: TokenFieldSegment[]): TokenSegmentList { - return new TokenizingSegmentList(segments, this.tokenRegex); + createSegmentList(segments: TokenFieldSegment[]): this { + let Constructor = this.constructor as new ( + tokens: TokenFieldSegment[], + tokenRegex: RegExp + ) => this; + return new Constructor(segments, this.tokenRegex); } tokenize(text: string): TokenFieldSegment[] { @@ -265,6 +268,7 @@ class TagFieldSegmentList extends TokenSegmentList { export const TagField: TokenFieldStory = () => { return ( { ); }; +class ComboBoxSegmentList extends TokenSegmentList { + getSelectedKeys(): Key[] { + return this.segments.filter(seg => seg.type === 'token').map(seg => seg.value!); + } + + getInputValue(): string { + let segment = this.segments[this.caretPosition.index]; + return segment?.type === 'text' ? segment.text : ''; + } + + setSelectedKeys(keys: Key[]): ComboBoxSegmentList { + let selectedKeys = this.getSelectedKeys(); + let added: Key[] = []; + for (let key of keys) { + if (!selectedKeys.includes(key)) { + added.push(key); + } + } + let removed = new Set(); + for (let key of selectedKeys) { + if (!keys.includes(key)) { + removed.add(key); + } + } + + let value = this; + for (let key of removed) { + let index = value.segments.findIndex(seg => seg.type === 'token' && seg.value! === key); + value = value.replaceRangeWithSegments( + {index: index, offset: 0}, + {index: index, offset: value.segments[index]?.text.length ?? 0}, + [], + false + ); + } + + if (added.length > 0) { + // TODO: if the user selects multiple existing segments and then selects a value from the menu, + // should we replace the selected segments? + let segment = value.segments[value.caretPosition.index]; + value = value.replaceRangeWithSegments( + {index: value.caretPosition.index, offset: 0}, + // If caret is in a text segment, replace the text segment with the new token. Otherwise, insert it. + segment?.type === 'text' + ? { + index: value.caretPosition.index, + offset: value.segments[value.caretPosition.index]?.text.length ?? 0 + } + : {index: value.caretPosition.index, offset: 0}, + added.map(value => { + // TODO: add a way to lookup a custom text value + return {type: 'token', text: String(value), value: value}; + }), + false + ); + } + + return value; + } +} + export const ComboBoxExample: TokenFieldStory = () => { + let [value, setValue] = useState(new ComboBoxSegmentList([])); + return ( - + setValue(value.setSelectedKeys(keys))}>
- + + {segment => {segment.text}} +
- {state => {state.username}} + {state => {state.username}}
); }; - -function ComboBoxTagInput() { - let state = useContext(ComboBoxStateContext); - let inputCtx = useSlottedContext(InputContext); - let [value, setValue] = useState(() => { - let selectedItems: TokenFieldSegment[] = - state?.selectedItems.map(item => ({ - type: 'token' as const, - text: item.textValue, - value: item.value - })) ?? []; - selectedItems.push({type: 'text', text: state?.inputValue ?? ''}); - return new TokenSegmentList(selectedItems); - }); - - let [lastSelectedItems, setLastSelectedItems] = useState(state?.selectedItems || []); - let [lastInputValue, setLastInputValue] = useState(state?.inputValue ?? ''); - - if ( - state && - (state?.selectedItems !== lastSelectedItems || lastInputValue !== state?.inputValue) - ) { - setValue(value => { - let selected = state?.selectedItems ?? []; - let selectedValues = new Set(selected.map(item => item.value)); - - let segments = value.segments.filter( - seg => seg.type === 'text' || selectedValues.has(seg.value) - ); - - let existingValues = new Set( - segments.filter(seg => seg.type === 'token').map(seg => seg.value) - ); - - let newTokens: TokenFieldSegment[] = selected - .filter(item => !existingValues.has(item.value)) - .map(item => ({ - type: 'token' as const, - text: item.textValue, - value: item.value - })); - - let caret = value.caretPosition; - let removedBeforeCaret = 0; - for (let i = 0; i < caret.index && i < value.segments.length; i++) { - let seg = value.segments[i]; - if (seg.type === 'token' && !selectedValues.has(seg.value)) { - removedBeforeCaret++; - } - } - - let insertIndex = Math.min(caret.index - removedBeforeCaret, segments.length); - segments.splice(insertIndex, 0, ...newTokens); - - if (!segments.some(seg => seg.type === 'text')) { - segments.push({type: 'text', text: state?.inputValue ?? ''}); - } - - let caretIndex = Math.min(insertIndex + newTokens.length, segments.length - 1); - if (segments[caretIndex]?.type === 'text') { - segments[caretIndex] = {type: 'text', text: state?.inputValue ?? ''}; - } - - let caretPosition = { - index: caretIndex, - offset: - segments[caretIndex]?.type === 'text' ? (state?.inputValue ?? '').length : caret.offset - }; - - return new TokenSegmentList(segments, {caretPosition}); - }); - setLastSelectedItems(state?.selectedItems || []); - setLastInputValue(state?.inputValue ?? ''); - } - - return ( - { - let segment = list.segments[list.caretPosition.index]; - state?.setInputValue(segment?.type === 'text' ? segment.text : ''); - setValue(list); - }} - aria-label="Users" - style={{paddingInlineEnd: 36}}> - {segment => {segment.text}} - - ); -} diff --git a/packages/@react-spectrum/ai/stories/styles.global.css b/packages/@react-spectrum/ai/stories/styles.global.css index 15b2cac8eef..665027e5685 100644 --- a/packages/@react-spectrum/ai/stories/styles.global.css +++ b/packages/@react-spectrum/ai/stories/styles.global.css @@ -7,7 +7,7 @@ border-radius: 8px; outline: none; width: 100%; - max-width: 500px; + max-width: min(85vw, 500px); border: 2px solid var(--gray-400); background-color: var(--gray-50); font: var(--font-size) system-ui; diff --git a/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx b/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx index 59418fd84b6..34a70b33408 100644 --- a/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx +++ b/packages/@react-spectrum/ai/test/TokenField.browser.test.tsx @@ -18,15 +18,19 @@ import { abTokCd, adjacentTokensSample, + dblClickAt, expectCaret, focusField, getFieldSelection, + isMacPlatform, + isWindowsPlatform, modKey, navigateCaret, navigateCaretFromEnd, renderControlledTokenField, segments, selectRange, + setFieldSelection, text, token, waitForCaret, @@ -35,12 +39,12 @@ import { wordDeleteModKey, wordNavModKey } from './utils/tokenFieldBrowserUtils'; +import {CLIPBOARD_MIME_TYPE, Token, TokenField} from '../src/TokenField'; import {commands, userEvent} from 'vitest/browser'; import {describe, expect, it} from 'vitest'; import {isFirefox, isWebKit} from 'react-aria/private/utils/platform'; import React from 'react'; import {render} from 'vitest-browser-react'; -import {Token, TokenField} from '../src/TokenField'; declare module 'vitest/browser' { interface BrowserCommands { @@ -51,6 +55,22 @@ declare module 'vitest/browser' { // Conditionally skip the suite const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; + +// Word-forward caret movement (Selection.modify with word granularity) stops at the end of the +// current word on macOS + Linux, and in Firefox on every platform. Chromium and WebKit on Windows +// instead advance to the start of the next word. The component delegates to the browser's native +// behavior, so the destination follows this platform/engine convention. +const wordForwardStopsAtWordEnd = () => !isWindowsPlatform() || isFirefox(); + +// Playwright's bundled WebKit cannot read the system clipboard back on Windows, so copy/cut → +// paste round trips deliver no data. These tests pass against WebKit on macOS but not elsewhere. +const clipboardRoundTripUnsupported = () => isWebKit() && isWindowsPlatform(); + +// Firefox additionally strips non-standard clipboard types on paste (only text/plain, text/html, +// etc. survive), so the custom token MIME type — and thus token structure — cannot round trip off +// macOS. WebKit off macOS fails the same assertion for the round-trip reason above. +const customClipboardTypeUnsupported = () => (isWebKit() || isFirefox()) && !isMacPlatform(); + describeOrSkip('TokenField browser interactions', () => { describe('rendering', () => { it('should render textbox and tokens', async () => { @@ -62,6 +82,57 @@ describeOrSkip('TokenField browser interactions', () => { await expect.element(screen.getByRole('textbox', {name: 'Message'})).toBeInTheDocument(); await expect.element(screen.getByText('TOK')).toBeInTheDocument(); }); + + it('does not allow editing when read only', async () => { + let {textbox, getValue} = await renderControlledTokenField(abTokCd, {isReadOnly: true}); + let el = textbox.element(); + expect(el.getAttribute('contenteditable')).toBe('false'); + expect(el.getAttribute('aria-readonly')).toBe('true'); + await focusField(textbox); + await userEvent.keyboard('x'); + expect(getValue().toString()).toBe('abTOKcd'); + }); + + it('is not editable or focusable when disabled', async () => { + let {textbox} = await renderControlledTokenField(abTokCd, {isDisabled: true}); + let el = textbox.element(); + expect(el.getAttribute('contenteditable')).toBe('false'); + expect(el.getAttribute('aria-disabled')).toBe('true'); + expect(el.getAttribute('tabindex')).toBeNull(); + }); + }); + + describe('focus management', () => { + function FocusHarness() { + let [value, setValue] = React.useState(() => segments(text('hello'))); + return ( + <> + setValue(segments(text(e.target.value)))} + /> + + {segment => {segment.text}} + + + ); + } + + it('does not focus the field on mount', async () => { + let screen = await render(); + await expect.element(screen.getByRole('textbox', {name: 'Focus test'})).not.toHaveFocus(); + }); + + it('does not steal focus when the value changes while another element is focused', async () => { + let screen = await render(); + let other = screen.getByRole('textbox', {name: 'Other field'}); + await userEvent.click(other); + await expect.element(other).toHaveFocus(); + // Typing into the other input updates the TokenField value; focus must stay on the input. + await userEvent.type(other, 'x'); + await expect.element(other).toHaveFocus(); + await expect.element(screen.getByRole('textbox', {name: 'Focus test'})).not.toHaveFocus(); + }); }); describe('caret movement', () => { @@ -126,6 +197,17 @@ describeOrSkip('TokenField browser interactions', () => { await waitForSelection(textbox, {index: 0, offset: 0}); }); + it('moves over an emoji as a single grapheme', async () => { + let list = segments(text('a😀b')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 1}); + // ArrowRight steps over the whole emoji (2 code units), not into the middle of it. + await userEvent.keyboard('{ArrowRight}'); + await waitForSelection(textbox, {index: 0, offset: 3}); + await userEvent.keyboard('{ArrowLeft}'); + await waitForSelection(textbox, {index: 0, offset: 1}); + }); + it('moves to field start with Home on single line', async () => { let list = segments(text('hello')); let {textbox} = await renderControlledTokenField(list); @@ -173,7 +255,7 @@ describeOrSkip('TokenField browser interactions', () => { await navigateCaret(textbox, list, {index: 0, offset: 0}); let mod = wordNavModKey(); await userEvent.keyboard(`{${mod}>}{ArrowRight}{/${mod}}`); - await waitForSelection(textbox, {index: 0, offset: 5}); + await waitForSelection(textbox, {index: 0, offset: wordForwardStopsAtWordEnd() ? 5 : 6}); }); it('word navigation skips over token backward as atomic unit', async () => { @@ -211,6 +293,128 @@ describeOrSkip('TokenField browser interactions', () => { }); }); + describe('rtl caret movement', () => { + // In RTL text the visual arrow keys map to the opposite logical direction: ArrowLeft moves + // the caret forward through the text and ArrowRight moves it backward. + it('moves the caret in the visual direction in RTL text', async () => { + let list = segments(text('אבגד')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + setFieldSelection(el, {index: 0, offset: 2}, {index: 0, offset: 2}); + await userEvent.keyboard('{ArrowLeft}'); + await waitForSelection(textbox, {index: 0, offset: 3}); + setFieldSelection(el, {index: 0, offset: 2}, {index: 0, offset: 2}); + await userEvent.keyboard('{ArrowRight}'); + await waitForSelection(textbox, {index: 0, offset: 1}); + }); + + it('skips over a token in the visual direction in RTL text', async () => { + let list = segments(text('אב'), token('ך'), text('גד')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + // ArrowLeft (visual left = logical forward) crosses the token to the following text. + setFieldSelection(el, {index: 0, offset: 2}, {index: 0, offset: 2}); + await userEvent.keyboard('{ArrowLeft}'); + await waitForSelection(textbox, {index: 2, offset: 0}); + // ArrowRight (visual right = logical backward) crosses back over the token. + await userEvent.keyboard('{ArrowRight}'); + await waitForSelection(textbox, {index: 0, offset: 2}); + }); + + it('extends the selection in the visual direction in RTL text', async () => { + let list = segments(text('אבגד')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + setFieldSelection(el, {index: 0, offset: 2}, {index: 0, offset: 2}); + await userEvent.keyboard('{Shift>}{ArrowLeft}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 2}, {index: 0, offset: 4}); + }); + + it('extends the selection across a token in RTL text', async () => { + let list = segments(text('אב'), token('ך'), text('גד')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + setFieldSelection(el, {index: 0, offset: 2}, {index: 0, offset: 2}); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 2}, {index: 2, offset: 0}); + }); + + it('moves the caret by word in the visual direction in RTL text', async () => { + // "שלום עולם" = two words separated by a space (offsets 0-4 and 5-9). + let list = segments(text('שלום עולם')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + let mod = wordNavModKey(); + // Word + ArrowLeft (visual left = logical forward) moves by a word: to the end of the first + // word where word-forward stops at the word end, otherwise to the start of the next word. + setFieldSelection(el, {index: 0, offset: 0}, {index: 0, offset: 0}); + await userEvent.keyboard(`{${mod}>}{ArrowLeft}{/${mod}}`); + await waitForSelection(textbox, {index: 0, offset: wordForwardStopsAtWordEnd() ? 4 : 5}); + // Word + ArrowRight (visual right = logical backward) moves to the start of the last word. + setFieldSelection(el, {index: 0, offset: 9}, {index: 0, offset: 9}); + await userEvent.keyboard(`{${mod}>}{ArrowRight}{/${mod}}`); + await waitForSelection(textbox, {index: 0, offset: 5}); + }); + + it('moves to the line boundaries with Home and End in RTL text', async () => { + let list = segments(text('שלום עולם')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + setFieldSelection(el, {index: 0, offset: 4}, {index: 0, offset: 4}); + await userEvent.keyboard('{Home}'); + await waitForSelection(textbox, {index: 0, offset: 0}); + await userEvent.keyboard('{End}'); + await waitForSelection(textbox, {index: 0, offset: 9}); + }); + }); + + describe('bidirectional text', () => { + it('deletes the previous character with Backspace in RTL text', async () => { + let list = segments(text('שלום')); + let {textbox, getValue} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + setFieldSelection(el, {index: 0, offset: 4}, {index: 0, offset: 4}); + await userEvent.keyboard('{Backspace}'); + await waitForFieldText(getValue, 'שלו'); + }); + + it('crosses a token atomically in mixed RTL and LTR text', async () => { + // RTL text, an LTR token, then RTL text. + let list = segments(text('שלום'), token('ABC'), text('עולם')); + let {textbox} = await renderControlledTokenField(list, {dir: 'rtl'} as any); + let el = textbox.element(); + await focusField(textbox); + // From the boundary before the token, ArrowLeft (logical forward) jumps the whole token. + setFieldSelection(el, {index: 0, offset: 4}, {index: 0, offset: 4}); + await userEvent.keyboard('{ArrowLeft}'); + await waitForSelection(textbox, {index: 2, offset: 0}); + // ArrowRight (logical backward) crosses back over the token. + await userEvent.keyboard('{ArrowRight}'); + await waitForSelection(textbox, {index: 0, offset: 4}); + }); + + it('moves through and back across mixed bidirectional text', async () => { + // "ab" (LTR) + "גד" (RTL) + "ef" (LTR). The visual path differs across browsers, but the + // caret must traverse the whole field and a round trip must return to the start. + let list = segments(text('abגדef')); + let {textbox} = await renderControlledTokenField(list); + await focusField(textbox); + await userEvent.keyboard('{Home}'); + await waitForSelection(textbox, {index: 0, offset: 0}); + await userEvent.keyboard('{ArrowRight>6}'); + await waitForSelection(textbox, {index: 0, offset: 6}); + await userEvent.keyboard('{ArrowLeft>6}'); + await waitForSelection(textbox, {index: 0, offset: 0}); + }); + }); + describe('selection', () => { it('selects token atomically when extending selection across it', async () => { let {textbox} = await renderControlledTokenField(abTokCd); @@ -285,6 +489,106 @@ describeOrSkip('TokenField browser interactions', () => { await expect(tokenEl.getAttribute('data-selected')).toBe('true'); }); + it('extends selection to the left with Shift+ArrowLeft', async () => { + let list = segments(text('abcde')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 3}); + await userEvent.keyboard('{Shift>}{ArrowLeft}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 3}); + }); + + it('moves selection back to the right with Shift+ArrowRight without extending past the anchor', async () => { + let list = segments(text('abcde')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 3}); + // Extend left to select "bc". + await userEvent.keyboard('{Shift>}{ArrowLeft}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 3}); + // Shift+ArrowRight shrinks the selection from the left rather than extending it to the right. + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 2}, {index: 0, offset: 3}); + }); + + it('extends back to the left after moving right with Shift+ArrowLeft', async () => { + let list = segments(text('abcde')); + let {textbox} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 3}); + // Extend left, move back right, then extend left again. The anchor stays fixed at offset 3. + await userEvent.keyboard('{Shift>}{ArrowLeft}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 3}); + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 2}, {index: 0, offset: 3}); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 3}); + }); + + it('keeps direction when shrinking a selection across a token', async () => { + let {textbox} = await renderControlledTokenField(abTokCd); + let tokenEl = textbox.getByText('TOK').element(); + // Caret after the token (start of "cd"). Extend left across the token to select it. + await navigateCaretFromEnd(textbox, abTokCd, {index: 2, offset: 0}); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: 0, offset: 2}, {index: 2, offset: 0}); + await expect.poll(() => tokenEl.getAttribute('data-selected')).toBe('true'); + // Shift+ArrowRight shrinks back across the token rather than extending to the right. + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await waitForSelection(textbox, {index: 2, offset: 0}, {index: 2, offset: 0}); + await expect.poll(() => tokenEl.getAttribute('data-selected')).toBe(null); + }); + + it('collapses the selection to the right edge with ArrowRight', async () => { + let {textbox} = await renderControlledTokenField(abTokCd); + await selectRange(textbox, abTokCd, {index: 0, offset: 1}, {index: 2, offset: 1}); + // A plain arrow collapses to the edge without moving the caret past it. + await userEvent.keyboard('{ArrowRight}'); + await waitForSelection(textbox, {index: 2, offset: 1}); + }); + + it('collapses the selection to the left edge with ArrowLeft', async () => { + let {textbox} = await renderControlledTokenField(abTokCd); + await selectRange(textbox, abTokCd, {index: 0, offset: 1}, {index: 2, offset: 1}); + await userEvent.keyboard('{ArrowLeft}'); + await waitForSelection(textbox, {index: 0, offset: 1}); + }); + + it('extends a double-clicked word to the left with Shift+ArrowLeft', async () => { + if (isFirefox() || !isMacPlatform()) { + // Double-click word selections are only directionless on macOS (and not in Firefox). + // On Windows/Linux the selection is anchored at the word start, so Shift+ArrowLeft + // shrinks it from the right rather than extending it to the left. + return; + } + + // Double clicking a word creates a directionless selection. Shift+ArrowLeft should + // extend it to the left rather than shrinking it from the right. + let list = segments(text('aaaa bbbb cccc')); + let {textbox} = await renderControlledTokenField(list); + let el = textbox.element(); + await focusField(textbox); + await dblClickAt(textbox, el.childNodes[0], 6); // inside "bbbb" + let sel = getFieldSelection(el)!; + let [start, end] = sel; + // The double click should have selected a whole word away from the field boundaries. + expect(start.offset).toBeGreaterThan(0); + expect(end.offset).toBeGreaterThan(start.offset); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitForSelection(textbox, {index: start.index, offset: start.offset - 1}, end); + }); + + it('extends a double-clicked word to the right with Shift+ArrowRight', async () => { + let list = segments(text('aaaa bbbb cccc')); + let {textbox} = await renderControlledTokenField(list); + let el = textbox.element(); + await focusField(textbox); + await dblClickAt(textbox, el.childNodes[0], 6); // inside "bbbb" + let sel = getFieldSelection(el)!; + let [start, end] = sel; + expect(end.offset).toBeGreaterThan(start.offset); + expect(end.offset).toBeLessThan(14); + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await waitForSelection(textbox, start, {index: end.index, offset: end.offset + 1}); + }); + it('extends selection backward by word with word selection shortcut', async () => { let list = segments(text('hello world')); let {textbox} = await renderControlledTokenField(list); @@ -300,7 +604,11 @@ describeOrSkip('TokenField browser interactions', () => { await navigateCaret(textbox, list, {index: 0, offset: 0}); let mod = wordNavModKey(); await userEvent.keyboard(`{Shift>}{${mod}>}{ArrowRight}{/${mod}}{/Shift}`); - await waitForSelection(textbox, {index: 0, offset: 0}, {index: 0, offset: 5}); + await waitForSelection( + textbox, + {index: 0, offset: 0}, + {index: 0, offset: wordForwardStopsAtWordEnd() ? 5 : 6} + ); }); it('extends selection backward by word across token', async () => { @@ -351,6 +659,7 @@ describeOrSkip('TokenField browser interactions', () => { it('inserts at clicked position in text', async () => { let {textbox, getValue} = await renderControlledTokenField(segments(text('hello'))); + await focusField(textbox); let el = textbox.element(); let textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE) as Text; let range = document.createRange(); @@ -365,8 +674,9 @@ describeOrSkip('TokenField browser interactions', () => { it('replaces token when typing after clicking token', async () => { let {textbox, getValue} = await renderControlledTokenField(abTokCd); + await focusField(textbox); await userEvent.click(textbox.getByText('TOK')); - await waitForSelection(textbox, {index: 1, offset: 0}, {index: 1, offset: 3}); + await waitForSelection(textbox, {index: 0, offset: 2}, {index: 2, offset: 0}); await userEvent.keyboard('NEW'); await waitForFieldText(getValue, 'abNEWcd'); }); @@ -456,7 +766,10 @@ describeOrSkip('TokenField browser interactions', () => { await navigateCaret(textbox, multiline, {index: 0, offset: 11}); let mod = modKey(); await userEvent.keyboard(`{${mod}>}{Backspace}{/${mod}}`); - await waitForFieldText(getValue, 'hello'); + // 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'); }); it('deletes forward to end of line with line-delete forward shortcut', async () => { @@ -473,14 +786,80 @@ describeOrSkip('TokenField browser interactions', () => { if (mac) { await userEvent.keyboard('{Control>}k{/Control}'); } else { - await userEvent.keyboard('{Control>}Delete{/Control}'); + await userEvent.keyboard('{Control>}{Delete}{/Control}'); } await waitForFieldText(getValue, 'hello\nw'); }); }); + describe('multiline', () => { + it('does not insert a newline on Enter in a single-line field', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + await userEvent.type(textbox, 'ab'); + await userEvent.keyboard('{Enter}'); + await userEvent.type(textbox, 'c'); + await waitForFieldText(getValue, 'abc'); + }); + + it('inserts a newline on Enter in a multiline field', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text('')), { + multiline: true + }); + await focusField(textbox); + await userEvent.type(textbox, 'ab'); + await userEvent.keyboard('{Enter}'); + await userEvent.type(textbox, 'c'); + await waitForFieldText(getValue, 'ab\nc'); + }); + + it('removes newlines from pasted text in a single-line field', async () => { + if (clipboardRoundTripUnsupported()) { + return; + } + // Put multiline text on the clipboard by copying it from a source field. + let source = await renderControlledTokenField(segments(text('a\nb')), {multiline: true}); + let target = await renderControlledTokenField(segments(text(''))); + let mod = modKey(); + await commands.lockClipboard(); + try { + await focusField(source.textbox); + await userEvent.keyboard(`{${mod}>}a{/${mod}}`); + await userEvent.copy(); + await focusField(target.textbox); + await userEvent.paste(); + await waitForFieldText(target.getValue, 'a b'); + } finally { + await commands.unlockClipboard(); + } + }); + + it('keeps newlines from pasted text in a multiline field', async () => { + if (clipboardRoundTripUnsupported()) { + return; + } + let source = await renderControlledTokenField(segments(text('a\nb')), {multiline: true}); + let target = await renderControlledTokenField(segments(text('')), {multiline: true}); + let mod = modKey(); + await commands.lockClipboard(); + try { + await focusField(source.textbox); + await userEvent.keyboard(`{${mod}>}a{/${mod}}`); + await userEvent.copy(); + await focusField(target.textbox); + await userEvent.paste(); + await waitForFieldText(target.getValue, 'a\nb'); + } finally { + await commands.unlockClipboard(); + } + }); + }); + describe('clipboard', () => { it('pastes plain text at caret', async () => { + if (clipboardRoundTripUnsupported()) { + return; + } let list = segments(text('ab')); let {textbox, getValue} = await renderControlledTokenField(list); await selectRange(textbox, list, {index: 0, offset: 0}, {index: 0, offset: 2}); @@ -496,6 +875,9 @@ describeOrSkip('TokenField browser interactions', () => { }); it('copy and paste preserves token segments within field', async () => { + if (customClipboardTypeUnsupported()) { + return; + } let {textbox, getValue} = await renderControlledTokenField(abTokCd); await selectRange(textbox, abTokCd, {index: 0, offset: 0}, {index: 2, offset: 2}); await commands.lockClipboard(); @@ -529,6 +911,9 @@ describeOrSkip('TokenField browser interactions', () => { }); it('cut removes selection and paste restores elsewhere', async () => { + if (clipboardRoundTripUnsupported()) { + return; + } let {textbox, getValue} = await renderControlledTokenField(segments(text('hello'))); await selectRange( textbox, @@ -547,6 +932,44 @@ describeOrSkip('TokenField browser interactions', () => { await commands.unlockClipboard(); } }); + + it('ignores malformed data under the custom clipboard type without crashing', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text('hi'))); + let el = textbox.element(); + await focusField(textbox); + // Simulate another app placing invalid JSON under our custom clipboard type. The field must + // not throw (it previously called JSON.parse unguarded) and its value must be unchanged. + let dt = new DataTransfer(); + dt.setData(CLIPBOARD_MIME_TYPE, '{ not valid json'); + el.dispatchEvent( + new ClipboardEvent('paste', {clipboardData: dt, bubbles: true, cancelable: true}) + ); + expect(getValue().toString()).toBe('hi'); + }); + + it('ignores valid JSON with invalid segments under the custom clipboard type', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text('hi'))); + let el = textbox.element(); + await focusField(textbox); + // Valid JSON that does not match the segment shape must be rejected by validation rather + // than trusted and inserted. Covers the non-array and per-segment validation branches. + for (let payload of [ + '{"type":"text","text":"x"}', // not an array + '[]', // empty array + '[{"type":"evil","text":"x"}]', // invalid segment type + '[{"type":"text"}]', // missing text + '[{"type":"token","text":42}]', // non-string text + '["plain string"]', // not an object + '[null]' // null entry + ]) { + let dt = new DataTransfer(); + dt.setData(CLIPBOARD_MIME_TYPE, payload); + el.dispatchEvent( + new ClipboardEvent('paste', {clipboardData: dt, bubbles: true, cancelable: true}) + ); + } + expect(getValue().toString()).toBe('hi'); + }); }); describe('undo and redo', () => { @@ -570,6 +993,25 @@ describeOrSkip('TokenField browser interactions', () => { await waitForFieldText(getValue, ''); }); + it('coalesces consecutive typing in a second field instance', async () => { + // Reproduces a bug where two instances of a TokenField share state. + let tick = () => new Promise(resolve => setTimeout(resolve, 30)); + await renderControlledTokenField(segments(text(''))); + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + await userEvent.keyboard('x'); + await tick(); + await userEvent.keyboard('y'); + await tick(); + await userEvent.keyboard('z'); + await tick(); + await waitForFieldText(getValue, 'xyz'); + let mod = modKey(); + await userEvent.keyboard(`{${mod}>}z{/${mod}}`); + await tick(); + expect(getValue().toString()).toBe(''); + }); + it('redoes after undo', async () => { let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); await focusField(textbox); diff --git a/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx b/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx new file mode 100644 index 00000000000..a7167a73987 --- /dev/null +++ b/packages/@react-spectrum/ai/test/TokenField.composition.browser.test.tsx @@ -0,0 +1,418 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * IME composition tests that emulate soft-keyboard (Android) text entry. + * + * Android's keyboard (Gboard/Samsung) routes nearly all editing — including plain + * English typing, autocorrect, the suggestion bar, and backspace — through + * non-cancelable composition events, and frequently recomposes the entire current + * word rather than the single keystroke. We reproduce that here with real composition + * events driven over CDP (Input.imeSetComposition / Input.insertText), which produce + * trusted events AND real contentEditable DOM mutation, unlike synthetic dispatchEvent. + * + * CDP is Chromium-only, so these tests are gated to Chromium. + */ + +import { + abTokCd, + focusField, + isMacPlatform, + modKey, + navigateCaret, + renderControlledTokenField, + segments, + setFieldSelection, + text, + token, + waitForFieldText +} from './utils/tokenFieldBrowserUtils'; +import {commands, userEvent} from 'vitest/browser'; +import {describe, expect, it} from 'vitest'; +import {isFirefox, isWebKit} from 'react-aria/private/utils/platform'; +import React from 'react'; +import {render} from 'vitest-browser-react'; +import {Token, TokenField} from '../src/TokenField'; +import {TokenSegmentList} from '../src/TokenSegmentList'; + +declare module 'vitest/browser' { + interface BrowserCommands { + lockClipboard: () => Promise; + unlockClipboard: () => void; + setComposition: ( + text: string, + selectionStart: number, + selectionEnd: number, + replacementStart?: number, + replacementEnd?: number + ) => Promise; + commitComposition: (text: string) => Promise; + } +} + +/** + * Types a word the way a soft keyboard does: grows an active composition one grapheme + * at a time (compositionstart + a series of compositionupdate), then commits it. If + * `commit` differs from the typed string it emulates an autocorrect/replacement on commit. + */ +async function composeWord(word: string, commit = word) { + for (let i = 1; i <= word.length; i++) { + let sub = word.slice(0, i); + await commands.setComposition(sub, sub.length, sub.length); + } + await commands.commitComposition(commit); +} + +/** Visible text of the field (DOM), with the token zero-width-space markers removed. */ +function domText(textbox: {element: () => Element}): string { + return textbox.element().textContent?.replace(/​/g, '') ?? ''; +} + +/** Asserts the user-visible DOM text, which can diverge from the model after a composition. */ +async function expectDOMText(textbox: {element: () => Element}, str: string) { + await expect.poll(() => domText(textbox)).toBe(str); +} + +// CDP composition is Chromium only. +const itAndroid = isFirefox() || isWebKit() ? it.skip : it; +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; + +describeOrSkip('TokenField IME composition (Android)', () => { + itAndroid('types a word via composition into an empty field', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + await composeWord('hello'); + await waitForFieldText(getValue, 'hello'); + await expectDOMText(textbox, 'hello'); + }); + + itAndroid('types via composition after existing text', async () => { + let list = segments(text('ab')); + let {textbox, getValue} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 2}); + await composeWord('cd'); + await waitForFieldText(getValue, 'abcd'); + await expectDOMText(textbox, 'abcd'); + }); + + itAndroid('commits an autocorrected word', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + // User types "teh", keyboard autocorrects to "the" on commit. + await composeWord('teh', 'the'); + await waitForFieldText(getValue, 'the'); + await expectDOMText(textbox, 'the'); + }); + + itAndroid('types via composition after a token', async () => { + let {textbox, getValue} = await renderControlledTokenField(abTokCd); + // Caret just after the token (start of trailing "cd"). + await navigateCaret(textbox, abTokCd, {index: 2, offset: 0}); + await composeWord('x'); + await waitForFieldText(getValue, 'abTOKxcd'); + await expectDOMText(textbox, 'abTOKxcd'); + }); + + itAndroid('backspaces within an active composition', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + // Grow an active (uncommitted) composition, then shrink it, then commit. + await commands.setComposition('h', 1, 1); + await commands.setComposition('he', 2, 2); + await commands.setComposition('hel', 3, 3); + await commands.setComposition('hell', 4, 4); + await commands.setComposition('hello', 5, 5); + await commands.setComposition('hell', 4, 4); + await commands.setComposition('hel', 3, 3); + await commands.commitComposition('hel'); + await waitForFieldText(getValue, 'hel'); + await expectDOMText(textbox, 'hel'); + }); + + // Android recomposes the whole current word (autocorrect, suggestion bar) by replacing + // the existing characters. Driven via Input.imeSetComposition replacement offsets + // (absolute, [start,end) into the text). Negative/caret-relative offsets crash the renderer. + itAndroid('recomposes the whole current word', async () => { + let list = segments(text('hello')); + let {textbox, getValue} = await renderControlledTokenField(list); + await navigateCaret(textbox, list, {index: 0, offset: 5}); + await commands.setComposition('HELLO', 5, 5, 0, 5); + await commands.commitComposition('HELLO'); + await waitForFieldText(getValue, 'HELLO'); + await expectDOMText(textbox, 'HELLO'); + }); + + itAndroid('composes a word immediately before a token', async () => { + // Caret at the very start, before "ab" / token / "cd". + let {textbox, getValue} = await renderControlledTokenField(abTokCd); + await navigateCaret(textbox, abTokCd, {index: 0, offset: 0}); + await composeWord('x'); + await waitForFieldText(getValue, 'xabTOKcd'); + await expectDOMText(textbox, 'xabTOKcd'); + }); + + itAndroid('composes between two adjacent tokens', async () => { + let list = new TokenSegmentList([token('A'), token('B')]); + let {textbox, getValue} = await renderControlledTokenField(list); + // Caret between the two tokens. + await navigateCaret(textbox, list, {index: 1, offset: 0}); + await composeWord('x'); + await waitForFieldText(getValue, 'AxB'); // text "x" inserted between tokens A and B + await expectDOMText(textbox, 'AxB'); + }); + + // Regression test for on-device duplication when typing immediately after a leading token. + // The browser composes into a text node it places after the contentEditable=false token; + // without the compositionend DOM-reconciliation the DOM would show 'TOKhellohello' even + // though the model is the correct 'TOKhello'. + itAndroid('composes immediately after a lone leading token without duplicating', async () => { + let list = new TokenSegmentList([token('TOK')]); + let {textbox, getValue} = await renderControlledTokenField(list); + await focusField(textbox); + await userEvent.keyboard('{End}'); + await composeWord('hello'); + await waitForFieldText(getValue, 'TOKhello'); // model is correct + await expectDOMText(textbox, 'TOKhello'); // DOM must match the model, not 'TOKhellohello' + }); + + itAndroid('replaces a clicked (selected) token via composition', async () => { + let {textbox, getValue} = await renderControlledTokenField(abTokCd); + await focusField(textbox); + // Clicking a token selects the whole element (userSelect: all). + let tokenEl = textbox.element().querySelector('[contenteditable="false"]') as HTMLElement; + await userEvent.click(tokenEl); + await composeWord('x'); + await waitForFieldText(getValue, 'abxcd'); + await expectDOMText(textbox, 'abxcd'); + }); + + itAndroid('replaces a selected token via composition (boundary range)', async () => { + let {textbox, getValue} = await renderControlledTokenField(abTokCd); + await focusField(textbox); + // End of "ab" through start of "cd" — spans the token, the shape a click produces. + setFieldSelection(textbox.element(), {index: 0, offset: 2}, {index: 2, offset: 0}); + await composeWord('x'); + await waitForFieldText(getValue, 'abxcd'); + await expectDOMText(textbox, 'abxcd'); + }); + + // If the controlled parent re-renders the field while a composition is active, + // React reconciliation can clobber the text node the IME is composing into, + // corrupting or duplicating input. Here we force an external re-render. + itAndroid('survives an external re-render mid-composition', async () => { + let forceRender: () => void = () => {}; + function Harness() { + let [value, setValue] = React.useState(() => segments(text(''))); + let [, setTick] = React.useState(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + forceRender = () => setTick(t => t + 1); + return ( + + {segment => {segment.text}} + + ); + } + let screen = await render(); + let textbox = screen.getByRole('textbox', {name: 'rerender-field'}); + await focusField(textbox); + + await commands.setComposition('h', 1, 1); + await commands.setComposition('he', 2, 2); + forceRender(); // parent re-renders while composition is active + await commands.setComposition('hel', 3, 3); + await commands.setComposition('hell', 4, 4); + await commands.commitComposition('hello'); + + await expectDOMText(textbox, 'hello'); + }); + + // Android sometimes fires compositionstart and then a regular (non-composing) input event + // without ever firing compositionend, leaving the field stuck in composing state (render + // blocked, DOM diverged from the model). The beforeinput handler ends composition when a + // non-composing event arrives mid-composition. We reproduce the exact sequence with CDP: + // setComposition starts a composition, then a real keypress fires insertText (isComposing + // false) with no compositionend in between. + itAndroid('recovers when a non-composing input arrives before compositionend', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + + await commands.setComposition('h', 1, 1); + await userEvent.keyboard('x'); + + // The component is not stuck composing: the DOM re-rendered to match the model (a stuck + // composition would leave the blocked DOM diverged from the model), with both characters + // present and no duplication. + await expect.poll(() => domText(textbox)).toBe(getValue().toString()); + expect(getValue().toString()).toHaveLength(2); + expect([...getValue().toString()].sort().join('')).toBe('hx'); + + // The field still accepts normal edits afterward (composition state was cleared). + await userEvent.keyboard('z'); + await expect.poll(() => getValue().toString().length).toBe(3); + }); + + itAndroid('replaces a multi-character text selection via composition', async () => { + let list = segments(text('hello')); + let {textbox, getValue} = await renderControlledTokenField(list); + await focusField(textbox); + setFieldSelection(textbox.element(), {index: 0, offset: 0}, {index: 0, offset: 5}); + await composeWord('z'); + await waitForFieldText(getValue, 'z'); + await expectDOMText(textbox, 'z'); + }); + + // Redo goes through the same compose-aware guard as undo: while composing it returns false + // so the browser handles it, rather than running the component's model redo (which would + // desync the model from the IME-controlled DOM). + itAndroid('lets the browser handle redo during composition', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + let mod = modKey(); + await focusField(textbox); + await composeWord('ab'); + await userEvent.keyboard(`{${mod}>}z{/${mod}}`); // undo (not composing) -> '' + await commands.setComposition('q', 1, 1); // start a composition (model -> 'q') + + let redo = isMacPlatform() ? `{Shift>}{${mod}>}z{/${mod}}{/Shift}` : '{Control>}y{/Control}'; + await userEvent.keyboard(redo); + + // The component did not redo: the model stays at the composed 'q' and matches the DOM. + await expect.poll(() => getValue().toString()).toBe('q'); + expect(domText(textbox)).toBe(getValue().toString()); + }); + + // Triggering undo mid-composition must not run the component's model undo: that would + // revert the model while the IME keeps mutating the DOM, leaving the two out of sync. + // Instead the shortcut is ignored (returns false) so the browser handles undo natively. + itAndroid('lets the browser handle undo during composition', async () => { + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + let mod = modKey(); + await focusField(textbox); + await composeWord('hi'); // commit a word to build undo history + await commands.setComposition('x', 1, 1); // start a new composition (model -> 'hix') + + await userEvent.keyboard(`{${mod}>}z{/${mod}}`); + + // The component did not undo: the model is unchanged and still matches the DOM. + // (Without the fix, the model would revert to '' while the DOM still shows 'hix'.) + let domText = () => textbox.element().textContent?.replace(/​/g, '') ?? ''; + await expect.poll(() => getValue().toString()).toBe('hix'); + expect(domText()).toBe(getValue().toString()); + }); + + // onChange must fire for each composition update (before compositionend) so external UI + // such as autocomplete can react to the in-progress text. + itAndroid('emits onChange during composition', async () => { + let changes: string[] = []; + function Harness() { + let [value, setValue] = React.useState(() => segments(text(''))); + return ( + { + changes.push(v.toString()); + setValue(v); + }} + aria-label="onchange-field"> + {segment => {segment.text}} + + ); + } + let screen = await render(); + let textbox = screen.getByRole('textbox', {name: 'onchange-field'}); + await focusField(textbox); + + // Grow an active composition WITHOUT committing. + await commands.setComposition('h', 1, 1); + await commands.setComposition('he', 2, 2); + await commands.setComposition('hel', 3, 3); + + // Updates were emitted mid-composition, before any compositionend. + await expect.poll(() => changes.at(-1)).toBe('hel'); + expect(changes).toContain('h'); + + await commands.commitComposition('hel'); + await expectDOMText(textbox, 'hel'); + }); + + // While composing, onChange fires (so state updates) but React must not re-render the + // editable subtree — re-rendering would reset the DOM nodes and break the IME. We assert + // a token child is not re-invoked during composition, then is once composition ends. + itAndroid('does not re-render the editable content until composition ends', async () => { + let renderCount = 0; + function CountingToken({children}: {children: string}) { + renderCount++; + return {children}; + } + function Harness() { + // Leading token (rendered via the child fn) followed by editable text. + let [value, setValue] = React.useState(() => new TokenSegmentList([token('A'), text('')])); + return ( + + {segment => {segment.text}} + + ); + } + let screen = await render(); + let textbox = screen.getByRole('textbox', {name: 'norerender-field'}); + await focusField(textbox); + // Caret after the token. + await userEvent.keyboard('{End}'); + + let countBefore = renderCount; + await commands.setComposition('h', 1, 1); + await commands.setComposition('he', 2, 2); + await commands.setComposition('hel', 3, 3); + + // onChange has updated state, but the editable content was not re-rendered. + expect(renderCount).toBe(countBefore); + + await commands.commitComposition('hel'); + // Composition ended -> the content re-renders to match the model. + await expect.poll(() => renderCount).toBeGreaterThan(countBefore); + await expectDOMText(textbox, 'Ahel'); + }); + + // If a controlled update arrives mid-composition that doesn't match the composed value + // (e.g. the user picks an autocomplete completion), composition ends early and the DOM + // re-renders so it matches the controlled value rather than the in-progress composition. + itAndroid('ends composition early when a controlled update inserts a completion', async () => { + let valueRef = {current: segments(text(''))}; + let setValueExternal: (v: TokenSegmentList) => void = () => {}; + function Harness() { + let [value, setValue] = React.useState(() => segments(text(''))); + valueRef.current = value; + // eslint-disable-next-line react-hooks/exhaustive-deps + setValueExternal = setValue; + return ( + + {segment => {segment.text}} + + ); + } + let screen = await render(); + let textbox = screen.getByRole('textbox', {name: 'completion-field'}); + await focusField(textbox); + + // User composes the start of a word. + await commands.setComposition('c', 1, 1); + await commands.setComposition('ca', 2, 2); + await commands.setComposition('cal', 3, 3); + await expect.poll(() => valueRef.current.toString()).toBe('cal'); + + // The user selects a completion: the parent replaces the value with a token. This does + // not match the in-progress composition, so composition ends early and the DOM updates. + setValueExternal(new TokenSegmentList([token('Calendar')])); + + await expectDOMText(textbox, 'Calendar'); + expect(valueRef.current.toString()).toBe('Calendar'); + }); +}); diff --git a/packages/@react-spectrum/ai/test/TokenSegmentList.test.ts b/packages/@react-spectrum/ai/test/TokenSegmentList.test.ts index a8275cefa72..a4b1a27fb1f 100644 --- a/packages/@react-spectrum/ai/test/TokenSegmentList.test.ts +++ b/packages/@react-spectrum/ai/test/TokenSegmentList.test.ts @@ -39,8 +39,12 @@ class TokenizingSegmentList extends TokenSegmentList { return new this(segments, tokenRegex); } - createSegmentList(segments: TokenFieldSegment[]): TokenSegmentList { - return new TokenizingSegmentList(segments, this.tokenRegex); + createSegmentList(segments: TokenFieldSegment[]): this { + const Constructor = this.constructor as new ( + tokens: TokenFieldSegment[], + tokenRegex: RegExp + ) => this; + return new Constructor(segments, this.tokenRegex); } tokenize(text: string): TokenFieldSegment[] { @@ -958,38 +962,6 @@ describeOrSkip('TokenSegmentList', () => { }); }); - describe('insertToken', () => { - it('turns entire text segment into a token', () => { - let list = new TokenSegmentList([text('hello')]); - let {segments: value, caretPosition: caret} = list.insertToken({index: 0, offset: 0}); - expect(value).toEqual([token('hello')]); - expect(caret).toEqual({index: 1, offset: 0}); - }); - - it('ignores offset and still tokenizes full text run', () => { - let list = new TokenSegmentList([text('hello')]); - let {segments: value, caretPosition: caret} = list.insertToken({index: 0, offset: 3}); - expect(value).toEqual([token('hello')]); - expect(caret).toEqual({index: 1, offset: 0}); - }); - - it('no-op when index points at token', () => { - let segs = [text('a'), token('T')]; - let list = new TokenSegmentList(segs); - let {segments: value, caretPosition: caret} = list.insertToken({index: 1, offset: 0}); - expect(value).toEqual(segs); - expect(caret).toEqual({index: 1, offset: 0}); - }); - - it('no-op when index is out of range', () => { - let segs = [text('a')]; - let list = new TokenSegmentList(segs); - let {segments: value, caretPosition: caret} = list.insertToken({index: 5, offset: 0}); - expect(value).toEqual(segs); - expect(caret).toEqual({index: 5, offset: 0}); - }); - }); - describe('undo/redo', () => { it('returns the same instance when there is nothing to undo', () => { let list = new TokenSegmentList([text('a')]); @@ -1090,15 +1062,6 @@ describeOrSkip('TokenSegmentList', () => { expect(initial.redo()).toBe(other); }); - it('insertToken is a separate undo step from coalesced typing', () => { - let list = new TokenSegmentList([text('hi')]); - let typed = list.replaceRange({index: 0, offset: 2}, {index: 0, offset: 2}, '!'); - let tokenized = typed.insertToken({index: 0, offset: 0}); - expect(tokenized.segments).toEqual([token('hi!')]); - expect(tokenized.undo()).toBe(typed); - expect(typed.undo()).toBe(list); - }); - it('coalesces consecutive delete operations into one undo step', () => { let list = new TokenSegmentList([text('abc')]); let afterC = list.delete({index: 0, offset: 3}, graphemeSegmenter, Direction.Backward); diff --git a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx b/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx index e28548a57ca..80f9e2b25f7 100644 --- a/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx @@ -40,11 +40,11 @@ export const abTokCd = segments(text('ab'), token('TOK'), text('cd')); /** Story sample for adjacent-token arrow navigation. */ export const adjacentTokensSample = new TokenSegmentList([ - {type: 'token', text: 'Hello'}, - {type: 'text', text: ' tokens testing '}, - {type: 'token', text: 'World'}, - {type: 'token', text: 'Testing'}, - {type: 'text', text: ' test'} + token('Hello'), + text(' tokens testing '), + token('World'), + token('Testing'), + text(' test') ]); export function expectFieldText(value: TokenSegmentList, str: string) { @@ -68,6 +68,10 @@ export function isMacPlatform(): boolean { return /^Mac/i.test(navigator.platform); } +export function isWindowsPlatform(): boolean { + return /^Win/i.test(navigator.platform); +} + /** Returns Meta on Mac, Control elsewhere (matches TokenField undo/redo). */ export function modKey(): 'Meta' | 'Control' { return isMacPlatform() ? 'Meta' : 'Control'; @@ -97,6 +101,26 @@ export function setFieldSelection(textboxEl: Element, start: Position, end: Posi setSelection(textboxEl, start, end); } +/** + * Double clicks on the character at the given offset within a DOM node, selecting the word + * under the cursor. Used to create a real (directionless) word selection, since selecting + * programmatically via addRange sets a direction in some browsers. + */ +export async function dblClickAt(textbox: Locator, node: Node, offset: number): Promise { + let el = textbox.element(); + let range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + 1); + let elRect = el.getBoundingClientRect(); + let charRect = range.getBoundingClientRect(); + await userEvent.dblClick(textbox, { + position: { + x: charRect.left - elRect.left + charRect.width / 2, + y: charRect.top - elRect.top + charRect.height / 2 + } + }); +} + export async function navigateCaret(textbox: Locator, list: TokenSegmentList, target: Position) { await focusField(textbox); await userEvent.keyboard('{Home}'); diff --git a/packages/@react-spectrum/s2/test/Combobox.browser.test.tsx b/packages/@react-spectrum/s2/test/Combobox.browser.test.tsx index c8e5c99201d..8ba7b288b5d 100644 --- a/packages/@react-spectrum/s2/test/Combobox.browser.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.browser.test.tsx @@ -40,7 +40,8 @@ function ComboBoxExample() { ); } -it.each` +// Flaky in CI. Getting Error: Expected listbox element to not be in the document after selecting an option. +it.skip.each` interactionType ${'mouse'} ${'keyboard'} diff --git a/packages/@react-spectrum/s2/test/DropZone.browser.test.tsx b/packages/@react-spectrum/s2/test/DropZone.browser.test.tsx index 2d89aaafcce..6f75dddb879 100644 --- a/packages/@react-spectrum/s2/test/DropZone.browser.test.tsx +++ b/packages/@react-spectrum/s2/test/DropZone.browser.test.tsx @@ -15,6 +15,7 @@ import {dragAndDrop} from './utils/dragAndDrop'; import {DropZone} from '../src/DropZone'; import {FileTrigger} from 'react-aria-components/FileTrigger'; import {IllustratedMessage} from '../src/IllustratedMessage'; +import {isMac} from 'react-aria/private/utils/platform'; import {page} from 'vitest/browser'; import React from 'react'; import {render} from './utils/render'; @@ -39,7 +40,10 @@ function Draggable({type}: {type: string}) { ); } -describe('DropZone browser interactions', () => { +// These tests are flaky on Windows / Linux. +const describeOrSkip = isMac() ? describe : describe.skip; + +describeOrSkip('DropZone browser interactions', () => { it('should handle drag and drop of valid drop types', async () => { let onDrop = vi.fn(); diff --git a/packages/@react-spectrum/s2/test/Menu.browser.test.tsx b/packages/@react-spectrum/s2/test/Menu.browser.test.tsx index b7de2a4a0c4..073678009f8 100644 --- a/packages/@react-spectrum/s2/test/Menu.browser.test.tsx +++ b/packages/@react-spectrum/s2/test/Menu.browser.test.tsx @@ -40,7 +40,8 @@ function MenuExample({onAction}) { ); } -it.each` +// Flaky in CI. Getting Error: Expected focus after selecting an option to move away from the option. +it.skip.each` interactionType ${'mouse'} ${'keyboard'} diff --git a/packages/@react-spectrum/s2/test/Picker.browser.test.tsx b/packages/@react-spectrum/s2/test/Picker.browser.test.tsx index 1ce29a20769..74bd6b4201f 100644 --- a/packages/@react-spectrum/s2/test/Picker.browser.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.browser.test.tsx @@ -36,7 +36,8 @@ function PickerExample() { ); } -it.each` +// Flaky in CI. Getting Error: Expected the document.activeElement after selecting an option to be the select component trigger but got [object HTMLDivElement]. +it.skip.each` interactionType ${'mouse'} ${'keyboard'} diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 869d76f25ad..14502016b6e 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -31,6 +31,7 @@ import {CollectionBuilder} from 'react-aria/CollectionBuilder'; import {ComboBoxState, useComboBoxState} from 'react-stately/useComboBoxState'; import {createHideableComponent} from 'react-aria/private/collections/Hidden'; import {FieldErrorContext} from './FieldError'; +import {FieldInputContext} from './Autocomplete'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {FormContext} from './Form'; import {GlobalDOMAttributes, Key, RefObject} from '@react-types/shared'; @@ -177,7 +178,14 @@ export const ComboBox = /*#__PURE__*/ createHideableComponent(function ComboBox< }); // Contexts to clear inside the popover. -const CLEAR_CONTEXTS = [LabelContext, ButtonContext, InputContext, GroupContext, TextContext]; +const CLEAR_CONTEXTS = [ + LabelContext, + ButtonContext, + InputContext, + FieldInputContext, + GroupContext, + TextContext +]; interface ComboBoxInnerProps { props: ComboBoxProps; @@ -302,6 +310,15 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: ComboBoxInnerPr [LabelContext, {...labelProps, ref: labelRef}], [ButtonContext, {...buttonProps, ref: buttonRef, isPressed: state.isOpen}], [InputContext, {...inputProps, ref: inputRef}], + [ + FieldInputContext, + { + ...inputProps, + ref: inputRef, + value: state.inputValue, + onChange: v => state.setInputValue(v as string) + } as any + ], [OverlayTriggerStateContext, state], [ PopoverContext, diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx index 9fd8c58ec27..9c0ca0c142f 100644 --- a/packages/react-aria-components/src/utils.tsx +++ b/packages/react-aria-components/src/utils.tsx @@ -44,7 +44,7 @@ export type SlottedContextValue = SlottedValue | T | null | undefined; export type ContextValue = SlottedContextValue>; type ProviderValue = [Context, T]; -type ProviderValues = +type ProviderValues = | [ProviderValue] | [ProviderValue, ProviderValue] | [ProviderValue, ProviderValue, ProviderValue] @@ -126,17 +126,32 @@ type ProviderValues = ProviderValue, ProviderValue, ProviderValue + ] + | [ + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue, + ProviderValue ]; -interface ProviderProps { - values: ProviderValues; +interface ProviderProps { + values: ProviderValues; children: ReactNode; } -export function Provider({ +export function Provider({ values, children -}: ProviderProps): JSX.Element { +}: ProviderProps): JSX.Element { for (let [Context, value] of values) { // @ts-ignore children = {children}; diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 3dfb572a81e..d250a6b8b1f 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -163,10 +163,34 @@ function iconWrapperPlugin(): Plugin { let unlock: ((value: any) => void) | null = null; +// Cache one CDP session per page so we don't re-attach on every composition step. +// Chromium only — used by the IME/composition commands below. +const cdpSessions = new WeakMap>(); +function getCDP(page: any, context: any): Promise { + let session = cdpSessions.get(page); + if (!session) { + session = context.newCDPSession(page); + cdpSessions.set(page, session!); + } + return session!; +} + declare module 'vitest/browser' { interface BrowserCommands { lockClipboard: () => Promise; unlockClipboard: () => void; + // Drive a real IME composition via CDP to emulate soft-keyboard (e.g. Android) input. + // Chromium only. selectionStart/End and replacementStart/End are passed straight to + // Input.imeSetComposition (offsets are relative to the current caret). + setComposition: ( + text: string, + selectionStart: number, + selectionEnd: number, + replacementStart?: number, + replacementEnd?: number + ) => Promise; + // Commit text that doesn't come from a key press (finalizes an active composition). + commitComposition: (text: string) => Promise; } } @@ -259,6 +283,28 @@ export default defineConfig({ unlock(null); unlock = null; } + }, + setComposition: async ( + {page, context}: any, + text, + selectionStart, + selectionEnd, + replacementStart, + replacementEnd + ) => { + const cdp = await getCDP(page, context); + const params: Record = {text, selectionStart, selectionEnd}; + if (replacementStart != null) { + params.replacementStart = replacementStart; + } + if (replacementEnd != null) { + params.replacementEnd = replacementEnd; + } + await cdp.send('Input.imeSetComposition', params); + }, + commitComposition: async ({page, context}: any, text) => { + const cdp = await getCDP(page, context); + await cdp.send('Input.insertText', {text}); } } }, @@ -275,7 +321,7 @@ export default defineConfig({ extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', '.svg'], alias: { '@react-spectrum/s2/illustrations': path.resolve(s2Dir, 'spectrum-illustrations'), - '@react-spectrum/s2': path.resolve(s2Dir, 'src') + '@react-spectrum/s2': path.resolve(s2Dir, 'exports') } }, optimizeDeps: {