diff --git a/.changeset/commandtextarea-combobox-filter-focus-fixes.md b/.changeset/commandtextarea-combobox-filter-focus-fixes.md new file mode 100644 index 000000000..d0e0da806 --- /dev/null +++ b/.changeset/commandtextarea-combobox-filter-focus-fixes.md @@ -0,0 +1,11 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix several `CommandTextArea` issues and a related `ComboBox` filtering regression: + +- **Stale virtual focus / commit**: As the typed token narrows, virtual focus now moves to the first still-visible option when the previously highlighted option is filtered out, and `Enter`/`Tab` can no longer commit a hidden command (e.g. typing `/` then `h` no longer inserts `/clear`). Visible options are derived from the component's own filtered collection instead of the ListBox state ref, which can lag by one render. +- **Conditional Hook**: `listStateRef` no longer calls `useRef` conditionally, preventing the Hook-order changes that could occur when the optional `listStateRef` prop was added or removed. +- **Stale caret after external value updates**: When the textarea value changes from outside (controlled updates, form reset, or a seeded `defaultValue`), the caret is now resynced from the DOM selection so trigger parsing uses a valid index. +- **`defaultValue` ignored**: An uncontrolled `CommandTextArea` now seeds its text (and trigger parsing) from `defaultValue`. +- **`ComboBox` filtering**: `ComboBox` again filters on `textValue` only, as documented. The shared `filterCollectionNodes` helper now matches plain-text `children`/`description` only when a component opts in via `matchExtraFields` (used by `CommandTextArea`). diff --git a/.size-limit.cjs b/.size-limit.cjs index 93d878852..1ac8a293b 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,7 @@ module.exports = [ }), ); }, - limit: '410kB', + limit: '415kB', }, { name: 'Tree shaking (just a Button)', diff --git a/src/components/content/Item/Item.tsx b/src/components/content/Item/Item.tsx index 1849bf830..606eea335 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -912,6 +912,19 @@ const Item = ( return children; }, [children, highlight, highlightCaseSensitive, highlightStyles]); + // Process description with highlight if applicable + const processedDescription = useMemo(() => { + if (typeof description === 'string' && highlight) { + return highlightText( + description, + highlight, + highlightCaseSensitive, + highlightStyles, + ); + } + return description; + }, [description, highlight, highlightCaseSensitive, highlightStyles]); + // Render function that creates the item element const renderItemElement = ( tooltipTriggerProps?: HTMLAttributes, @@ -985,7 +998,7 @@ const Item = ( : null} {showDescription ? (
- {description} + {processedDescription}
) : null} {finalSuffix &&
{finalSuffix}
} diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index 9395dfbeb..9c7f4b1e1 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -23,12 +23,7 @@ import React, { useRef, useState, } from 'react'; -import { - useFilter, - useKeyboard, - useOverlay, - useOverlayPosition, -} from 'react-aria'; +import { useFilter, useKeyboard } from 'react-aria'; import { Section as BaseSection, useListState } from 'react-stately'; import { useEvent } from '../../../_internal'; @@ -36,21 +31,22 @@ import { CloseIcon, DirectionIcon, LoadingIcon } from '../../../icons'; import { useProviderProps } from '../../../provider'; import { FieldBaseProps } from '../../../shared'; import { generateRandomId } from '../../../utils/random'; -import { - mergeProps, - useCombinedRefs, - useLayoutEffect, -} from '../../../utils/react'; +import { useCombinedRefs, useLayoutEffect } from '../../../utils/react'; import { useFocus } from '../../../utils/react/interactions'; import { usePopoverSync } from '../../../utils/react/usePopoverSync'; import { extractStyles } from '../../../utils/styles'; import { CollectionItem as Item } from '../../CollectionItem'; import { useFieldProps, useFormProps, wrapWithField } from '../../form'; -import { DisplayTransition } from '../../helpers'; -import { Portal } from '../../portal'; import { InvalidIcon } from '../../shared/InvalidIcon'; import { ValidIcon } from '../../shared/ValidIcon'; -import { ListBox } from '../ListBox/ListBox'; +import { + filterCollectionNodes, + getEdgeVisibleKey, + getNextVisibleKey, + ListBoxPopover, + markKeyboardFocus, + useCompositeFocus, +} from '../ListBoxPopover'; import { DEFAULT_INPUT_STYLES, INPUT_WRAPPER_STYLES, @@ -70,59 +66,6 @@ const InputElement = tasty({ styles: DEFAULT_INPUT_STYLES, }); -const ComboBoxOverlayWrapper = tasty({ - qa: 'ComboBoxOverlayWrapper', - styles: { - position: 'absolute', - zIndex: 1000, - }, -}); - -const ComboBoxOverlayElement = tasty({ - qa: 'ComboBoxOverlay', - styles: { - display: 'grid', - gridRows: '1sf', - gridColumns: '1sf', - width: '$overlay-min-width max-content 50vw', - height: 'initial max-content (50vh - 5x)', - overflow: 'auto', - fill: '#surface', - radius: '1cr', - shadow: true, - padding: '0', - border: '#border', - hide: { - '': false, - hidden: true, - }, - boxSizing: 'border-box', - transition: - 'translate $transition ease-out, scale $transition ease-out, theme $transition ease-out', - translate: { - '': '0 0', - 'open & [data-placement="top"]': '0 0', - '!open & [data-placement="top"]': '0 1x', - 'open & ([data-placement="bottom"] | ![data-placement])': '0 0', - '!open & ([data-placement="bottom"] | ![data-placement])': '0 -1x', - }, - transformOrigin: { - '': 'top center', - '[data-placement="top"]': 'bottom center', - }, - scale: { - '': '1 1', - '!open': '1 .9', - }, - opacity: { - '': 1, - '!open': 0.001, - }, - - '$overlay-min-width': 'min 30x', - }, -}); - export interface CubeComboBoxProps extends BasePropsWithoutChildren, BaseStyleProps, @@ -361,28 +304,7 @@ function useComboBoxFiltering({ return nodes; } - // Filter nodes based on their textValue and preserve section structure - return [...nodes] - .map((node: any) => { - if (node.type === 'section' && node.childNodes) { - const filteredChildren = [...node.childNodes].filter((child: any) => - textFilterFn(child.textValue || '', term), - ); - - if (filteredChildren.length === 0) { - return null; - } - - return { - ...node, - childNodes: filteredChildren, - hasChildNodes: true, - }; - } - - return textFilterFn(node.textValue || '', term) ? node : null; - }) - .filter(Boolean); + return filterCollectionNodes(nodes, term, textFilterFn); }, [isFilterActive, effectiveInputValue, textFilterFn], ); @@ -394,85 +316,6 @@ function useComboBoxFiltering({ }; } -// ============================================================================ -// Hook: useCompositeFocus -// ============================================================================ -interface UseCompositeFocusProps { - wrapperRef: RefObject; - popoverRef: RefObject; - onFocus?: () => void; - onBlur?: () => void; - isDisabled?: boolean; -} - -interface UseCompositeFocusReturn { - compositeFocusProps: { - onFocus: (e: React.FocusEvent) => void; - onBlur: (e: React.FocusEvent) => void; - }; -} - -function useCompositeFocus({ - wrapperRef, - popoverRef, - onFocus, - onBlur, - isDisabled, -}: UseCompositeFocusProps): UseCompositeFocusReturn { - const wasInsideRef = useRef(false); - const rafRef = useRef(null); - - const checkFocus = useCallback(() => { - if (isDisabled) return; - - const activeElement = document.activeElement; - const isInside = - (wrapperRef.current?.contains(activeElement) ?? false) || - (popoverRef.current?.contains(activeElement) ?? false); - - if (isInside !== wasInsideRef.current) { - wasInsideRef.current = isInside; - if (isInside) { - onFocus?.(); - } else { - onBlur?.(); - } - } - }, [wrapperRef, popoverRef, onFocus, onBlur, isDisabled]); - - const handleFocusOrBlur = useCallback( - (e: React.FocusEvent) => { - // Cancel any pending check - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - } - - // Schedule focus check for next frame - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null; - checkFocus(); - }); - }, - [checkFocus], - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - } - }; - }, []); - - return { - compositeFocusProps: { - onFocus: handleFocusOrBlur, - onBlur: handleFocusOrBlur, - }, - }; -} - // ============================================================================ // Hook: useComboBoxKeyboard // ============================================================================ @@ -528,54 +371,12 @@ function useComboBoxKeyboard({ const listState = listStateRef.current; if (!listState) return; - const { selectionManager, collection, disabledKeys } = listState; - - // Helper to collect visible item keys (supports sections) - const collectVisibleKeys = (nodes: Iterable, out: Key[]) => { - for (const node of nodes) { - if (node.type === 'item') { - if (!disabledKeys?.has(node.key)) { - out.push(node.key); - } - } else if (node.childNodes) { - collectVisibleKeys(node.childNodes, out); - } - } - }; - - const visibleKeys: Key[] = []; - collectVisibleKeys(collection, visibleKeys); - - if (visibleKeys.length === 0) return; - const isArrowDown = e.key === 'ArrowDown'; - const currentKey = selectionManager.focusedKey; - - let nextKey: Key | null = null; - - if (currentKey == null) { - nextKey = isArrowDown - ? visibleKeys[0] - : visibleKeys[visibleKeys.length - 1]; - } else { - const currentIndex = visibleKeys.indexOf(currentKey); - if (currentIndex !== -1) { - const newIndex = currentIndex + (isArrowDown ? 1 : -1); - if (newIndex >= 0 && newIndex < visibleKeys.length) { - nextKey = visibleKeys[newIndex]; - } - } else { - nextKey = isArrowDown - ? visibleKeys[0] - : visibleKeys[visibleKeys.length - 1]; - } - } + const nextKey = getNextVisibleKey(listState, isArrowDown ? 1 : -1); if (nextKey != null) { - if (listState.lastFocusSourceRef) { - listState.lastFocusSourceRef.current = 'keyboard'; - } - selectionManager.setFocusedKey(nextKey); + markKeyboardFocus(listState); + listState.selectionManager.setFocusedKey(nextKey); } } else if (e.key === 'Enter') { // If popover is open, try to select the focused item first @@ -641,35 +442,15 @@ function useComboBoxKeyboard({ const listState = listStateRef.current; if (!listState) return; - const { selectionManager, collection, disabledKeys } = listState; - - // Helper to collect visible item keys (supports sections) - const collectVisibleKeys = (nodes: Iterable, out: Key[]) => { - for (const node of nodes) { - if (node.type === 'item') { - if (!disabledKeys?.has(node.key)) { - out.push(node.key); - } - } else if (node.childNodes) { - collectVisibleKeys(node.childNodes, out); - } - } - }; - - const visibleKeys: Key[] = []; - collectVisibleKeys(collection, visibleKeys); - - if (visibleKeys.length === 0) return; + const targetKey = getEdgeVisibleKey( + listState, + e.key === 'Home' ? 'first' : 'last', + ); - const targetKey = - e.key === 'Home' - ? visibleKeys[0] - : visibleKeys[visibleKeys.length - 1]; - - if (listState.lastFocusSourceRef) { - listState.lastFocusSourceRef.current = 'keyboard'; + if (targetKey != null) { + markKeyboardFocus(listState); + listState.selectionManager.setFocusedKey(targetKey); } - selectionManager.setFocusedKey(targetKey); } } }, @@ -773,177 +554,6 @@ const ComboBoxInput = forwardRef( }, ); -// ============================================================================ -// Component: ComboBoxOverlay -// ============================================================================ -interface ComboBoxOverlayProps { - isOpen: boolean; - triggerRef: RefObject; - popoverRef: RefObject; - listBoxRef: RefObject; - direction: 'bottom' | 'top'; - shouldFlip: boolean; - overlayOffset: number; - containerPadding: number; - comboBoxWidth?: number; - comboBoxId: string; - overlayStyles?: Styles; - listBoxStyles?: Styles; - optionStyles?: Styles; - sectionStyles?: Styles; - headingStyles?: Styles; - effectiveSelectedKey: Key | null; - isDisabled?: boolean; - disabledKeys?: Iterable; - items?: Iterable; - children: ReactNode; - listStateRef: RefObject; - onSelectionChange: (selection: Key | Key[] | null) => void; - onClose: () => void; - label?: ReactNode; - ariaLabel?: string; - compositeFocusProps: { - onFocus: (e: React.FocusEvent) => void; - onBlur: (e: React.FocusEvent) => void; - }; - filter?: (nodes: Iterable) => Iterable; - size?: 'small' | 'medium' | 'large' | (string & {}); -} - -function ComboBoxOverlay({ - isOpen, - triggerRef, - popoverRef, - listBoxRef, - direction, - shouldFlip, - overlayOffset, - containerPadding, - comboBoxWidth, - comboBoxId, - overlayStyles, - listBoxStyles, - optionStyles, - sectionStyles, - headingStyles, - effectiveSelectedKey, - isDisabled, - disabledKeys, - items, - children, - listStateRef, - onSelectionChange, - onClose, - label, - ariaLabel, - compositeFocusProps, - filter, - size = 'medium', -}: ComboBoxOverlayProps) { - // Overlay positioning - const { overlayProps: overlayPositionProps, placement } = useOverlayPosition({ - targetRef: triggerRef as any, - overlayRef: popoverRef as any, - placement: `${direction} start` as any, - shouldFlip, - isOpen, - offset: overlayOffset, - containerPadding: containerPadding, - }); - - // Overlay behavior (dismiss on outside click, escape) - const { overlayProps: overlayBehaviorProps } = useOverlay( - { - onClose, - shouldCloseOnBlur: true, - isOpen, - isDismissable: true, - shouldCloseOnInteractOutside: (el) => { - const menuTriggerEl = el.closest('[data-popover-trigger]'); - if (!menuTriggerEl) { - if (el.closest('[data-popover-keep]')) return false; - // Plain interactive controls (Button, ItemButton) opt in via - // `data-popover-dismiss` to dismiss us without losing their click - // to useOverlay's stopPropagation. Schedule the close after the - // click finishes so the button's onPress runs first. - if (el.closest('[data-popover-dismiss]')) { - setTimeout(onClose, 0); - return false; - } - return true; - } - if (menuTriggerEl === triggerRef?.current) return true; - return false; - }, - }, - popoverRef as any, - ); - - // Extract primary placement direction for consistent styling - const placementDirection = placement?.split(' ')[0] || direction; - - const overlayContent = ( - - {({ phase, isShown, ref: transitionRef }) => ( - - - - {children as any} - - - - )} - - ); - - return {overlayContent}; -} - // ============================================================================ // Main Component: ComboBox // ============================================================================ @@ -1814,7 +1424,7 @@ export const ComboBox = forwardRef(function ComboBox( /> ) : null} - } popoverRef={popoverRef} @@ -1824,13 +1434,13 @@ export const ComboBox = forwardRef(function ComboBox( overlayOffset={overlayOffset} containerPadding={containerPadding} comboBoxWidth={comboBoxWidth} - comboBoxId={comboBoxId} + listBoxId={`ComboBoxListBox-${comboBoxId}`} overlayStyles={overlayStyles} listBoxStyles={listBoxStyles} optionStyles={optionStyles} sectionStyles={sectionStyles} headingStyles={headingStyles} - effectiveSelectedKey={effectiveSelectedKey} + selectedKey={effectiveSelectedKey} isDisabled={isDisabled} disabledKeys={props.disabledKeys} items={sortedItems} @@ -1844,7 +1454,7 @@ export const ComboBox = forwardRef(function ComboBox( onClose={() => setIsPopoverOpen(false)} > {children} - + ); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx new file mode 100644 index 000000000..5fa8728fa --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx @@ -0,0 +1,161 @@ +import { Meta, Canvas, Story } from '@storybook/addon-docs/blocks'; +import { CommandTextArea } from './CommandTextArea'; +import * as CommandTextAreaStories from './CommandTextArea.stories'; + + + +# CommandTextArea + +A multiline text input (built on `TextArea` / `TextInputBase`) that shows a +command-autocomplete popover when the text matches a configurable trigger. The +focus always stays in the textarea; arrow keys, Enter/Tab, and Escape drive the +list only while it is open. Selecting an option inserts the option's literal +value (e.g. `/clear`) into the text and fires `onCommand`. + +## When to Use + +- Chat or prompt inputs where users type slash commands (`/clear`, `/help`). +- Inputs that need token-based autocomplete (`@mentions`, snippets) without + giving up free-form, multiline editing. +- Any textarea that should "feel like a textarea" but offer inline suggestions. + +## Component + + + +--- + +### Properties + +- **`triggers`** `CommandTrigger[]` (default: `[{ char: '/', atLineStart: true }]`) — Descriptors that open the autocomplete. `atLineStart` restricts the token to the start of the line the caret is on (after a newline, or at the very start of the input); `false` allows it anywhere after whitespace. +- **`items`** `Iterable` — Data-driven option source (alternative to JSX children). +- **`children`** `ReactNode | ((item) => ReactElement)` — Static items or a render function (same shape as `ComboBox` / `ListBox`). +- **`filter`** `(textValue, query) => boolean | false` — Custom filter. Pass `false` to disable internal filtering (e.g. server-side). +- **`onCommand`** `(key, item) => void` — Fired when the user picks a command. The option's `textValue` is inserted into the textarea regardless. +- **`insertSpaceAfter`** `boolean` (default: `true`) — Insert a trailing space after the chosen command. +- **`disabledKeys`** `Iterable` — Keys of disabled options. +- **`direction`** `'top' | 'bottom'` (default: `'top'`) — Popover placement. Chat inputs usually want `top`. +- **`shouldFlip`** `boolean` (default: `true`) — Whether the popover flips when overflowing. +- **`overlayOffset`** `number` (default: `8`) — Distance between the textarea and the popover. +- **`containerPadding`** `number` (default: `8`) — Padding between the popover and the viewport edge. +- **`autoSize`** `boolean` (default: `false`) — Resize to fit content. +- **`rows`** `number` (default: `3`) — Visible rows. +- **`maxRows`** `number` (default: `10`) — Max visible rows when `autoSize` is true. + +Each option's value is the **literal string to insert**. For commands, set the +item's `textValue` (and `key`) to the full `/clear` — the slash is part of both +the active token and the inserted value, so `/cle` becomes `/clear ` with no +duplication. This decouples insertion from the trigger, so non-command +autocomplete (e.g. a `@mention` resolving to `@john`) works the same way. + +> **What the filter matches.** The trigger char is stripped from the active +> token before matching (so `/co` searches for `co`), and the query is matched +> against each option's `textValue`, its plain-text `children`, and its +> plain-text `description`. This means an option can match by its description +> even though the description doesn't contain the trigger char (e.g. typing +> `/co` keeps `/clear` because its description "Clear conversation" contains +> "co"). A recommended layout is to put the command in `children` (e.g. +> `/clear`) and the human-readable text in the `description` prop (e.g. +> `Clear conversation`), keeping `textValue` equal to the command so it inserts +> correctly. + +The matched substring (the query with the trigger char stripped) is highlighted +within each option's plain-text `children` **and** `description`, so wherever the +query matched is visible. + +> **Popover anchors to the caret.** The autocomplete popover tracks the text +> caret on both axes — it opens at the caret's horizontal position and on the +> caret's current line (not pinned to the textarea's top/bottom edge). This +> matters for multiline inputs, where the caret can be on any line. The popover +> is clamped to the viewport, so it flips or shifts when the caret is near an +> edge. `direction` still controls whether it prefers to open above (`top`) or +> below (`bottom`) the caret. + +### Base Properties + +Supports [Base properties](/docs/getting-started-base-properties--docs). + +### Field Properties + +Supports all [Field properties](/docs/getting-started-field-properties--docs) +(`label`, `name`, `isRequired`, `isDisabled`, `isReadOnly`, `validationState`, +`description`, `rules`, etc.). + +### Styling Properties + +- **`overlayStyles`** — Styles for the popover overlay. +- **`listBoxStyles`** — Styles for the inner listbox. +- **`optionStyles`** — Styles for individual options. +- **`sectionStyles`** — Styles for section containers. +- **`headingStyles`** — Styles for section headings. + +### Style Properties + +These properties allow direct style application without using the `styles` prop: + +- **Base:** `display`, `font`, `preset`, `hide`, `whiteSpace`, `opacity`, `transition` +- **Position:** `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `scrollMargin` +- **Dimension:** `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex` +- **Block:** `border`, `radius`, `shadow`, `outline`, `padding`, `paddingInline`, `paddingBlock`, `overflow`, `scrollbar`, `textAlign` +- **Color:** `color`, `fill`, `fade`, `image` + +## Examples + +### Basic usage + +```jsx + console.log('picked', key)} +> + /clear + /help + +``` + +### Mention trigger (anywhere in the text) + +```jsx + + @john + @jane + +``` + +### Data-driven with sections + +```jsx + + {(item) => {item.key}} + +``` + +## Accessibility + +### Keyboard Navigation + +- `Tab` — Moves focus into / out of the textarea. +- `ArrowUp` / `ArrowDown` — Move the highlighted option (only while the popover is open; otherwise moves the caret). +- `Home` / `End` — Jump to the first / last option while open. +- `Enter` / `Tab` — Insert the highlighted option while open; otherwise insert a newline / submit. +- `Escape` — Close the popover without changing the text. + +### Screen Reader Support + +- The textarea is announced as a `combobox` with `aria-autocomplete="list"`. +- `aria-expanded`, `aria-controls`, and `aria-activedescendant` track the + popover and the highlighted option. + +## Integration with Forms + +This component supports all [Field properties](/docs/getting-started-field-properties--docs) when used within a Form. Its `value` is the full textarea string. + +## Related Components + +- [TextArea](/docs/forms-textarea--docs) — The base multiline field this builds on. +- [ComboBox](/docs/forms-combobox--docs) — Single-value select with search (different semantics: commits a `selectedKey`). +- [ListBox](/docs/forms-listbox--docs) — The virtual-focus list rendered inside the popover. diff --git a/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx new file mode 100644 index 000000000..e01ca16c1 --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx @@ -0,0 +1,295 @@ +import { StoryFn } from '@storybook/react-vite'; +import { useState } from 'react'; + +import { baseProps } from '../../../stories/lists/baseProps'; + +import { CommandTextArea } from './CommandTextArea'; + +export default { + title: 'Forms/CommandTextArea', + component: CommandTextArea, + parameters: { + controls: { + exclude: baseProps, + }, + }, + argTypes: { + value: { + control: { type: 'text' }, + description: 'The text value in controlled mode', + }, + defaultValue: { + control: { type: 'text' }, + description: 'The default text value in uncontrolled mode', + }, + placeholder: { + control: { type: 'text' }, + description: 'Placeholder text', + }, + triggers: { + control: { type: null }, + description: + 'Trigger descriptors that open the autocomplete (default: a slash command at the start of the input)', + }, + autoSize: { + control: { type: 'boolean' }, + description: 'Whether the textarea resizes to fit its content', + table: { defaultValue: { summary: false } }, + }, + rows: { + control: { type: 'number' }, + description: 'Number of visible rows', + table: { defaultValue: { summary: 3 } }, + }, + maxRows: { + control: { type: 'number' }, + description: 'Max visible rows when autoSize is true', + table: { defaultValue: { summary: 10 } }, + }, + direction: { + control: { type: 'inline-radio' }, + options: ['top', 'bottom'], + description: 'Popover placement', + table: { defaultValue: { summary: 'top' } }, + }, + insertSpaceAfter: { + control: { type: 'boolean' }, + description: 'Insert a trailing space after the chosen command', + table: { defaultValue: { summary: true } }, + }, + onCommand: { control: { type: null } }, + filter: { control: { type: null } }, + shouldFlip: { + control: { type: 'boolean' }, + description: 'Whether the popover flips when overflowing', + table: { defaultValue: { summary: true } }, + }, + overlayOffset: { + control: { type: 'number' }, + description: 'Distance between the textarea and the popover', + table: { defaultValue: { summary: 8 } }, + }, + containerPadding: { + control: { type: 'number' }, + description: 'Padding between the popover and the viewport edge', + table: { defaultValue: { summary: 8 } }, + }, + overlayStyles: { + control: { type: null }, + description: 'Styles for the popover overlay', + }, + listBoxStyles: { + control: { type: null }, + description: 'Styles for the inner listbox', + }, + optionStyles: { + control: { type: null }, + description: 'Styles for individual options', + }, + sectionStyles: { + control: { type: null }, + description: 'Styles for section containers', + }, + headingStyles: { + control: { type: null }, + description: 'Styles for section headings', + }, + disabledKeys: { + control: { type: null }, + description: 'Keys of disabled options', + }, + }, +}; + +const commands = [ + { key: '/clear', description: 'Clear conversation' }, + { key: '/help', description: 'Show help' }, + { key: '/share', description: 'Share conversation' }, + { key: '/summarize', description: 'Summarize thread' }, + { key: '/settings', description: 'Open settings' }, +]; + +export const Default: StoryFn = (props) => ( + + {commands.map((c) => ( + + {c.key} + + ))} + +); + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState(''); + return ( + { + console.log('picked command', key); + }} + > + {commands.map((c) => ( + + {c.key} + + ))} + + ); +}; + +export const MentionTrigger: StoryFn = (props) => ( + + {[ + { key: '@john', description: 'John Doe' }, + { key: '@jane', description: 'Jane Roe' }, + { key: '@alex', description: 'Alex Kim' }, + ].map((c) => ( + + {c.key} + + ))} + +); + +export const WithSections: StoryFn = (props) => ( + + + + /clear + + + /share + + + + + /help + + + /settings + + + +); + +export const AutoSize: StoryFn = (props) => ( + + {commands.map((c) => ( + + {c.key} + + ))} + +); + +export const Multiline: StoryFn = (props) => ( + + {commands.map((c) => ( + + {c.key} + + ))} + +); + +export const TopPopoverPosition: StoryFn = (props) => ( +
+ + {commands.map((c) => ( + + {c.key} + + ))} + +
+); +TopPopoverPosition.parameters = { + layout: 'fullscreen', +}; diff --git a/src/components/fields/CommandTextArea/CommandTextArea.test.tsx b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx new file mode 100644 index 000000000..e3100dc92 --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx @@ -0,0 +1,422 @@ +import { act, waitFor } from '@testing-library/react'; + +import { renderWithForm, renderWithRoot, userEvent } from '../../../test/index'; + +import { CommandTextArea } from './CommandTextArea'; + +vi.mock('../../../_internal/hooks/use-warn'); + +const commands = [ + { key: '/clear', description: 'Clear conversation', textValue: '/clear' }, + { key: '/help', description: 'Show help', textValue: '/help' }, + { key: '/share', description: 'Share conversation', textValue: '/share' }, + { + key: '/summarize', + description: 'Summarize thread', + textValue: '/summarize', + }, +]; + +const commandItems = commands.map((c) => ( + + {c.key} + +)); + +describe('', () => { + it('renders as a textarea and does not show a listbox by default', () => { + const { getByRole, queryByRole } = renderWithRoot( + + {commandItems} + , + ); + + expect(getByRole('combobox')).toBeInTheDocument(); + expect(queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('shows a listbox when a slash command is typed at the start', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox'); + + await userEvent.type(input, '/c'); + + await waitFor(() => { + expect(queryByRole('listbox')).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + it('does not show a listbox for a normal (non-command) query', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox'); + await userEvent.type(input, 'hello world'); + + expect(queryByRole('listbox')).not.toBeInTheDocument(); + expect(input).toHaveAttribute('aria-expanded', 'false'); + }); + + it('filters options by the typed token', async () => { + const { getByRole, getAllByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox'); + await userEvent.type(input, '/s'); + + await waitFor(() => { + // /share and /summarize match "/s" + expect(getAllByRole('option')).toHaveLength(2); + }); + }); + + it('matches an option by its (text) children when the textValue differs', async () => { + const { getByRole, getAllByRole, queryByRole } = renderWithRoot( + + + /alpha + + + /beta + + , + ); + + const input = getByRole('combobox'); + // "/al" matches neither textValue but does match the "/alpha" children. + await userEvent.type(input, '/al'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + await waitFor(() => { + const options = getAllByRole('option').map((o) => o.textContent); + expect(options).toEqual(['/alpha']); + }); + }); + + it('matches an option by its (text) description', async () => { + const { getByRole, getAllByRole, queryByRole } = renderWithRoot( + + + /clear + + + /help + + , + ); + + const input = getByRole('combobox'); + // "/re" isn't in any command's textValue/children, but the /clear option's + // description contains "/reset". + await userEvent.type(input, '/re'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + await waitFor(() => { + const options = getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0].textContent).toContain('/clear'); + }); + }); + + it('highlights the typed token within the matching option', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox'); + await userEvent.type(input, '/cl'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + await waitFor(() => { + const option = getByRole('option'); + const mark = option.querySelector('mark'); + expect(mark).not.toBeNull(); + // The trigger char is stripped from the query, so "cl" is highlighted + // within the "/clear" children. + expect(mark?.textContent).toBe('cl'); + }); + }); + + it('highlights the matched substring within the description', async () => { + const { getByRole, queryByRole } = renderWithRoot( + + + /clear + + , + ); + + const input = getByRole('combobox'); + // "/co" matches the description ("Clear c[o]nversation") but not the command. + await userEvent.type(input, '/co'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + await waitFor(() => { + const description = getByRole('option').querySelector( + '[data-element="Description"]', + ); + const mark = description?.querySelector('mark'); + expect(mark).not.toBeNull(); + expect(mark?.textContent?.toLowerCase()).toBe('co'); + }); + }); + + it('inserts the full command on Enter', async () => { + const onCommand = vi.fn(); + const { getByRole, queryByRole } = renderWithRoot( + + {commandItems} + , + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + await userEvent.type(input, '/cl'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + // Wait until the first option is virtually focused (auto-focus init). + await waitFor(() => + expect(input).toHaveAttribute( + 'aria-activedescendant', + 'ListBoxItem-/clear', + ), + ); + + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(input.value).toBe('/clear '); + }); + expect(onCommand).toHaveBeenCalledWith('/clear', expect.any(Object)); + }); + + it('closes the popover with Escape and keeps the text', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + await userEvent.type(input, '/c'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + + await userEvent.keyboard('{Escape}'); + + await waitFor(() => { + expect(queryByRole('listbox')).not.toBeInTheDocument(); + }); + expect(input.value).toBe('/c'); + }); + + it('navigates options with ArrowDown and inserts on Enter', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + await userEvent.type(input, '/'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + // Auto-focus lands on the first option (/clear). + await waitFor(() => + expect(input).toHaveAttribute( + 'aria-activedescendant', + 'ListBoxItem-/clear', + ), + ); + + await userEvent.keyboard('{ArrowDown}'); // -> /help (second option) + await waitFor(() => + expect(input).toHaveAttribute( + 'aria-activedescendant', + 'ListBoxItem-/help', + ), + ); + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(input.value).toBe('/help '); + }); + }); + + it('supports a configurable mention trigger anywhere in the text', async () => { + const { getByRole, queryByRole } = renderWithRoot( + + + John + + + Jane + + , + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + await userEvent.type(input, 'hi @j'); + + await waitFor(() => { + expect(queryByRole('listbox')).toBeInTheDocument(); + }); + // Wait for auto-focus to land on the first matching option (@john). + await waitFor(() => + expect(input).toHaveAttribute( + 'aria-activedescendant', + 'ListBoxItem-@john', + ), + ); + + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + // First matching option is @john (focus starts on the first visible item). + expect(input.value).toBe('hi @john '); + }); + }); + + it('opens the popover for a line-start trigger on a non-first line', async () => { + const { getByRole, queryByRole } = renderWithRoot( + + {commandItems} + , + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + // Caret is at the end of the default value; typing "/c" puts it at the + // start of the second line. + await userEvent.type(input, '/c'); + + await waitFor(() => { + expect(queryByRole('listbox')).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + it('does not open the popover when the line-start trigger is not at a line start', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + // "hi /c" — the slash is mid-line, not at a line start, so the default + // atLineStart trigger should not fire. + await userEvent.type(input, 'hi /c'); + + await waitFor(() => { + expect(queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + it('works without a form', async () => { + const { getByRole } = renderWithRoot( + {commandItems}, + ); + const input = getByRole('combobox'); + await act(async () => { + await userEvent.type(input, 'Hello'); + }); + expect(input).toHaveValue('Hello'); + }); + + it('integrates with Form', async () => { + const { getByRole, formInstance } = renderWithForm( + + {commandItems} + , + ); + const input = getByRole('combobox'); + await act(async () => { + await userEvent.type(input, 'Hello'); + }); + expect(formInstance.getFieldValue('message')).toEqual('Hello'); + }); + + it('mounts a caret anchor inside the input wrapper for popover positioning', async () => { + const { getByRole, queryByRole, container } = renderWithRoot( + {commandItems}, + ); + + // The anchor is a hidden, zero-size element kept inside the input wrapper + // so react-aria can measure it. jsdom can't lay out text, so we only + // assert presence/placement — not coordinates. + const anchorBefore = container.querySelector('[data-caret-anchor]'); + expect(anchorBefore).not.toBeNull(); + expect(anchorBefore?.closest('[data-qa="InputWrapper"]')).not.toBeNull(); + + const input = getByRole('combobox'); + await userEvent.type(input, '/c'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + const anchorDuring = container.querySelector('[data-caret-anchor]'); + expect(anchorDuring).not.toBeNull(); + expect(anchorDuring?.closest('[data-qa="InputWrapper"]')).not.toBeNull(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(queryByRole('listbox')).not.toBeInTheDocument()); + }); + + it('moves virtual focus off a filtered-out option as the token narrows', async () => { + const { getByRole, queryByRole } = renderWithRoot( + {commandItems}, + ); + + const input = getByRole('combobox') as HTMLTextAreaElement; + await userEvent.type(input, '/'); + + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); + // Auto-focus lands on the first option (/clear). + await waitFor(() => + expect(input).toHaveAttribute( + 'aria-activedescendant', + 'ListBoxItem-/clear', + ), + ); + + // Narrow the token so /clear no longer matches ("/h" -> /help, /share). + await userEvent.type(input, 'h'); + + // Focus must move to the first still-visible option instead of staying on + // the now-hidden /clear, so Enter commits a command the user can see. + await waitFor(() => + expect(input).toHaveAttribute( + 'aria-activedescendant', + 'ListBoxItem-/help', + ), + ); + + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(input.value).toBe('/help '); + }); + }); + + it('seeds an uncontrolled textarea from defaultValue', () => { + const { getByRole } = renderWithRoot( + + {commandItems} + , + ); + + expect(getByRole('combobox')).toHaveValue('Hello there'); + }); +}); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.tsx b/src/components/fields/CommandTextArea/CommandTextArea.tsx new file mode 100644 index 000000000..9edc45247 --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.tsx @@ -0,0 +1,786 @@ +import { Key } from '@react-types/shared'; +import { Styles } from '@tenphi/tasty'; +import React, { + ForwardedRef, + forwardRef, + ReactElement, + ReactNode, + RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useFilter, useTextField } from 'react-aria'; +import { Section as BaseSection, useListState } from 'react-stately'; + +import { useEvent } from '../../../_internal'; +import { useProviderProps } from '../../../provider'; +import { generateRandomId } from '../../../utils/random'; +import { chain, mergeProps, useCombinedRefs } from '../../../utils/react'; +import { + castNullableStringValue, + WithNullableValue, +} from '../../../utils/react/nullableValue'; +import { CollectionItem as Item } from '../../CollectionItem'; +import { useFieldProps, useFormProps } from '../../form'; +import { + collectVisibleKeys, + filterCollectionNodes, + getEdgeVisibleKey, + getNextVisibleKey, + ListBoxPopover, + ListStateLike, + markKeyboardFocus, + TextFilterFn, + useCompositeFocus, +} from '../ListBoxPopover'; +import { + CubeTextInputBaseProps, + TextInputBase, +} from '../TextInput/TextInputBase'; + +import { useCaretAnchor } from './useCaretAnchor'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single trigger character with optional placement constraint. */ +export interface CommandTrigger { + /** The character that opens the autocomplete (e.g. `/` or `@`). */ + char: string; + /** + * When true, the trigger token must start at the beginning of the line the + * caret is on (right after a newline, or at the very start of the input) — + * i.e. line-scoped slash commands. When false, the token may appear after + * any whitespace, anywhere in the text (Slack/Notion-style). + */ + atLineStart?: boolean; +} + +/** Result of scanning the textarea for an active trigger token. */ +export interface ActiveToken { + /** The matched trigger descriptor. */ + trigger: CommandTrigger; + /** The active token text, INCLUDING the trigger char (e.g. `/cle`). */ + token: string; + /** Inclusive start index of the token in the textarea value. */ + start: number; + /** Exclusive end index of the token (caret position while typing). */ + end: number; +} + +export type CommandTextAreaFilterFn = TextFilterFn; + +export interface CubeCommandTextAreaProps + extends Omit { + /** Whether the textarea should resize to fit its content. */ + autoSize?: boolean; + /** Max visible rows when `autoSize` is true. Defaults to 10. */ + maxRows?: number; + /** Number of visible rows. Defaults to 3. */ + rows?: number; + + /** Command/option source (alternative to JSX children). */ + items?: Iterable; + /** Static items or a render function (same shape as ComboBox/ListBox). */ + children?: ReactNode | ((item: T) => ReactElement); + /** + * Triggers that open the autocomplete. Defaults to a single slash command + * that must start at the beginning of the input. + */ + triggers?: CommandTrigger[]; + /** + * Custom filter. Pass `false` to disable internal filtering (server-side). + */ + filter?: CommandTextAreaFilterFn | false; + /** + * Fired when the user picks a command (Enter/Tab/click). The option's + * `textValue` is also inserted into the textarea regardless. + */ + onCommand?: (key: Key, item: { textValue: string; [k: string]: any }) => void; + /** Insert a trailing space after the chosen command. Defaults to true. */ + insertSpaceAfter?: boolean; + /** Keys of disabled options. */ + disabledKeys?: Iterable; + /** Popover placement. Chat inputs usually want `top`. Defaults to `top`. */ + direction?: 'bottom' | 'top'; + /** Whether the popover should flip when overflowing. Defaults to true. */ + shouldFlip?: boolean; + /** Offset between the textarea and the popover. */ + overlayOffset?: number; + /** Padding between the popover and the viewport edge. */ + containerPadding?: number; + + // Refs + /** Ref for the textarea element. */ + inputRef?: RefObject; + /** Ref for the wrapper element (popover anchor). */ + wrapperRef?: RefObject; + /** Ref for the popover overlay element. */ + popoverRef?: RefObject; + /** Ref for the listbox element. */ + listBoxRef?: RefObject; + /** Ref through which the internal list state is exposed. */ + listStateRef?: RefObject; + + // Style props + overlayStyles?: any; + listBoxStyles?: any; + optionStyles?: any; + sectionStyles?: any; + headingStyles?: any; +} + +const DEFAULT_TRIGGERS: CommandTrigger[] = [{ char: '/', atLineStart: true }]; + +// --------------------------------------------------------------------------- +// Trigger parsing +// --------------------------------------------------------------------------- + +/** + * Inspect the textarea contents up to the caret and find the active trigger + * token, if any. The token includes the trigger character and ends at the + * caret (so it grows as the user types). A token ends at whitespace or the end + * of the input. + */ +export function parseActiveToken( + value: string, + caret: number, + triggers: CommandTrigger[], +): ActiveToken | null { + if (!triggers.length) return null; + const before = value.slice(0, caret); + const after = value.slice(caret); + + for (const trigger of triggers) { + const ch = trigger.char; + if (trigger.atLineStart) { + // Line-start command: the trigger char must sit at the start of the line + // the caret is on (right after a newline, or at index 0), with no + // whitespace between it and the caret. On a single-line input with no + // newlines this is equivalent to "index 0 of the whole input". + const lineStart = before.lastIndexOf('\n') + 1; + const linePrefix = before.slice(lineStart); + if (linePrefix[0] !== ch) continue; + // No whitespace allowed within the token (after the trigger char). + const wsIndex = linePrefix.slice(1).search(/\s/); + if (wsIndex !== -1) continue; + if (!linePrefix) continue; + return { trigger, token: linePrefix, start: lineStart, end: caret }; + } + + // Caret-token mode: find the last trigger char that begins a token at or + // after a line start / whitespace boundary, with no whitespace between it + // and the caret. + const match = /(?:^|\s)([\S]+)$/.exec(before); + if (!match) continue; + const candidate = match[1]; + if (candidate[0] !== ch) continue; + // Ensure the token hasn't already been closed by whitespace after caret. + // (If there's non-whitespace after the caret, the user is mid-token.) + const start = caret - candidate.length; + // If immediately after the caret there's whitespace (or end), the token is + // "complete" — still offer completions as long as the caret sits on the + // token. We accept it. + void after; + return { trigger, token: candidate, start, end: caret }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +function CommandTextArea( + props: WithNullableValue>, + ref: ForwardedRef, +) { + props = castNullableStringValue(props); + props = useProviderProps(props); + props = useFormProps(props); + props = useFieldProps(props, { + defaultValidationTrigger: 'onBlur', + valuePropsMapper: ({ value, onChange }) => ({ + onChange, + value: value?.toString() ?? '', + }), + }); + + let { + qa, + autoSize = false, + isDisabled = false, + isReadOnly = false, + isRequired = false, + onChange, + maxRows = 10, + rows = 3, + labelProps: userLabelProps, + inputRef: propsInputRef, + value, + defaultValue, + placeholder, + // command props + items, + children: renderChildren, + triggers = DEFAULT_TRIGGERS, + filter, + onCommand, + insertSpaceAfter = true, + disabledKeys, + direction = 'top', + shouldFlip = true, + overlayOffset = 8, + containerPadding = 8, + overlayStyles, + listBoxStyles, + optionStyles, + sectionStyles, + headingStyles, + wrapperRef: propsWrapperRef, + popoverRef: propsPopoverRef, + listBoxRef: propsListBoxRef, + listStateRef: propsListStateRef, + onKeyDown, + onFocus, + onBlur, + ...otherProps + } = props; + + rows = Math.max(rows, 1); + maxRows = Math.max(maxRows, rows); + + // ---- refs ------------------------------------------------------------- + const localInputRef = useRef(null); + const inputRef = (propsInputRef ?? + localInputRef) as RefObject; + const wrapperRef = useCombinedRefs(propsWrapperRef); + const popoverRef = useCombinedRefs(propsPopoverRef); + const listBoxRef = useCombinedRefs(propsListBoxRef); + const localListStateRef = useRef(null); + const listStateRef = (propsListStateRef ?? + localListStateRef) as RefObject; + + const commandTextAreaId = useMemo(() => generateRandomId(), []); + + // ---- controlled / uncontrolled value ---------------------------------- + // When no `value` is provided (standalone, no form), track the text + // internally so trigger detection and commit still work. + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState( + (value as string) ?? (defaultValue as string) ?? '', + ); + const effectiveValue: string = isControlled + ? (value as string) + : internalValue; + const handleChange = useEvent((next: string) => { + if (!isControlled) setInternalValue(next); + onChange?.(next); + }); + + // ---- children / items normalization ----------------------------------- + let children: ReactNode = renderChildren as ReactNode; + const renderFn = renderChildren as unknown; + if (items && typeof renderFn === 'function') { + const itemsArray = Array.from(items as Iterable); + children = itemsArray.map((item, idx) => { + const rendered = (renderFn as (it: any) => ReactNode)(item); + if ( + React.isValidElement(rendered) && + (rendered as ReactElement).key == null + ) { + return React.cloneElement(rendered as ReactElement, { + key: (rendered as any)?.key ?? item?.key ?? idx, + }); + } + return rendered as ReactNode; + }); + } + + // ---- caret tracking --------------------------------------------------- + // The caret drives trigger detection. We keep it in state so a caret move + // (arrow keys without typing) re-renders and recomputes the active token. + const [caret, setCaret] = useState(() => effectiveValue.length); + const syncCaret = useEvent(() => { + const el = inputRef.current; + if (!el) return; + setCaret(el.selectionStart ?? el.value.length); + }); + + // ---- trigger detection ------------------------------------------------ + const activeToken = useMemo( + () => + parseActiveToken( + effectiveValue, + Math.min(caret, effectiveValue.length), + triggers, + ), + [effectiveValue, caret, triggers], + ); + + // The active token includes the trigger char (e.g. `/co`). Strip it before + // filtering so the query is matched against the meaningful text — this lets + // options match by their label/description (which don't contain the trigger + // char) as well as by their `textValue`. + const term = useMemo(() => { + if (!activeToken) return ''; + const { token, trigger } = activeToken; + return token.startsWith(trigger.char) + ? token.slice(trigger.char.length) + : token; + }, [activeToken]); + + // ---- filtering -------------------------------------------------------- + const { contains } = useFilter({ sensitivity: 'base' }); + const textFilterFn = useMemo( + () => (filter === false ? () => true : filter || contains), + [filter, contains], + ); + + // Local collection used to read option textValues and to count matches. + const localCollectionState = useListState({ + children: children as any, + items: items as any, + selectionMode: 'none', + }); + + const filterFn = useCallback( + (nodes: Iterable) => + filterCollectionNodes(nodes, term, textFilterFn, { + matchExtraFields: true, + }), + [term, textFilterFn], + ); + + // Keys of the currently visible (filtered) options, in display order. + // Derived from our own local collection + filter (rather than the ListBox's + // exposed state ref) so it is always in sync with the current token — the + // ListBox's `stateRef.collection` can momentarily lag by a render right after + // the token narrows, which would otherwise leave virtual focus on (and commit) + // an option that is no longer visible. + const visibleFilteredKeys = useMemo(() => { + if (!activeToken) return []; + const keys: Key[] = []; + const disabled = disabledKeys ? new Set(disabledKeys) : undefined; + collectVisibleKeys( + filterFn(localCollectionState.collection), + keys, + disabled, + ); + return keys; + }, [activeToken, filterFn, localCollectionState.collection, disabledKeys]); + + const filteredCount = visibleFilteredKeys.length; + + // ---- dismiss handling (Escape keeps the text but hides the popover) --- + const [dismissedToken, setDismissedToken] = useState(null); + // Bumped whenever we move the virtual focus so the textarea's + // `aria-activedescendant` (read from the ListBox's stateRef at render time) + // stays in sync — the ListBox's own state update does not re-render us. + const [, setFocusTick] = useState(0); + const bumpFocus = useEvent(() => setFocusTick((t) => t + 1)); + // Reset dismissal whenever the active token changes (user typed/backspaced). + useEffect(() => { + setDismissedToken((prev) => + prev != null && activeToken?.token !== prev ? null : prev, + ); + }, [activeToken?.token]); + + const isCommandMode = !!activeToken && activeToken.token !== dismissedToken; + const shouldShowPopover = isCommandMode && filteredCount > 0; + + // ---- caret-anchored popover ------------------------------------------ + // A zero-size element positioned at the caret (both axes) drives the + // popover's geometry; the wrapper remains the dismiss/outside-click trigger. + const { anchorRef: caretAnchorRef, positionApiRef } = useCaretAnchor({ + inputRef: inputRef as RefObject, + wrapperRef: wrapperRef as RefObject, + caret, + value: effectiveValue, + isActive: shouldShowPopover, + }); + + // ---- height autosize (mirrors TextArea) ------------------------------- + const adjustHeight = useEvent(() => { + const textarea = inputRef.current; + if (!textarea || !autoSize) return; + + textarea.style.height = 'auto'; + const computedStyle = getComputedStyle(textarea); + const paddingTop = parseFloat(computedStyle.paddingTop) || 0; + const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0; + const borderTop = parseFloat(computedStyle.borderTopWidth) || 0; + const borderBottom = parseFloat(computedStyle.borderBottomWidth) || 0; + const lineHeight = parseInt(computedStyle.lineHeight) || 20; + const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom; + const computedRows = Math.ceil(contentHeight / lineHeight); + const targetRows = Math.max(Math.min(computedRows, maxRows), rows); + const totalHeight = + targetRows * lineHeight + + paddingTop + + paddingBottom + + borderTop + + borderBottom; + textarea.style.height = `${totalHeight}px`; + }); + + // ---- useTextField (ARIA wiring for the textarea) ---------------------- + let { labelProps, inputProps } = useTextField( + { + ...otherProps, + value: effectiveValue, + placeholder, + isDisabled, + isReadOnly, + isRequired, + onChange: chain(handleChange, adjustHeight), + inputElementType: 'textarea', + } as any, + inputRef as any, + ); + + const mergedLabelProps = mergeProps(labelProps, userLabelProps); + + const useEnvironmentalEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + + useEnvironmentalEffect(() => { + if (!autoSize || !inputRef.current) return; + adjustHeight(); + const resizeObserver = new ResizeObserver(adjustHeight); + resizeObserver.observe(inputRef.current); + return () => resizeObserver.disconnect(); + }, [autoSize, inputRef.current]); + + useEnvironmentalEffect(() => { + if (autoSize && inputRef.current) { + adjustHeight(); + } + }, [effectiveValue]); + + // ---- caret restore after commit -------------------------------------- + const pendingCaretRef = useRef(null); + useEnvironmentalEffect(() => { + const el = inputRef.current; + if (!el) return; + + if (pendingCaretRef.current != null) { + // Restore caret after we programmatically inserted a command. + const pos = pendingCaretRef.current; + pendingCaretRef.current = null; + el.focus(); + el.setSelectionRange(pos, pos); + setCaret(pos); + return; + } + + // The value changed without our own commit (controlled update from + // outside, form reset, or defaultValue seeding). Resync the caret from + // the DOM selection so trigger parsing works off a valid index instead of + // a stale one. We intentionally do not steal focus here. + const domCaret = el.selectionStart ?? el.value.length; + setCaret((prev) => (prev === domCaret ? prev : domCaret)); + }, [effectiveValue]); + + // ---- helpers ---------------------------------------------------------- + const getItemTextValue = useCallback( + (key: Key): string => { + const item = localCollectionState?.collection?.getItem(key); + return item?.textValue || String(key); + }, + [localCollectionState?.collection], + ); + + // ---- commit (insert the chosen option's literal value) --------------- + const commit = useEvent((key: Key) => { + const listState = listStateRef.current; + // Guard against committing a stale key that has been filtered out of the + // visible list (e.g. the token was narrowed after focus landed on it). + // Fall back to the first still-visible option so the inserted command + // always matches what the user can see. + if (visibleFilteredKeys.length > 0 && !visibleFilteredKeys.includes(key)) { + key = visibleFilteredKeys[0]; + } + const textValue = getItemTextValue(key); + const token = activeToken; + const el = inputRef.current; + + if (token) { + const next = + effectiveValue.slice(0, token.start) + + textValue + + (insertSpaceAfter ? ' ' : '') + + effectiveValue.slice(token.end); + const caretPos = + token.start + textValue.length + (insertSpaceAfter ? 1 : 0); + pendingCaretRef.current = caretPos; + handleChange(next); + // If controlled, caret is restored by the effect above; if not, set now. + if (el) { + el.focus(); + try { + el.setSelectionRange(caretPos, caretPos); + } catch { + /* noop */ + } + } + } else if (el) { + // Fallback: insert at caret. + const start = el.selectionStart ?? effectiveValue.length; + const end = el.selectionEnd ?? effectiveValue.length; + const next = + effectiveValue.slice(0, start) + + textValue + + (insertSpaceAfter ? ' ' : '') + + effectiveValue.slice(end); + const caretPos = start + textValue.length + (insertSpaceAfter ? 1 : 0); + pendingCaretRef.current = caretPos; + handleChange(next); + } + + setDismissedToken(null); + onCommand?.(key, { + textValue, + ...(listState?.collection?.getItem(key)?.props ?? {}), + }); + }); + + // ---- keyboard navigation (only while the popover is open) ------------- + const onKeyDownHandler = useCallback( + (e: React.KeyboardEvent) => { + onKeyDown?.(e); + if (e.defaultPrevented) return; + + if (!shouldShowPopover) { + // While not in command mode, the textarea behaves normally; but clear + // any dismissal when the user edits. + return; + } + + const listState = listStateRef.current; + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (!listState) return; + e.preventDefault(); + const nextKey = getNextVisibleKey( + listState, + e.key === 'ArrowDown' ? 1 : -1, + ); + if (nextKey != null) { + markKeyboardFocus(listState); + listState.selectionManager.setFocusedKey(nextKey); + bumpFocus(); + } + } else if (e.key === 'Home' || e.key === 'End') { + if (!listState) return; + e.preventDefault(); + const edgeKey = getEdgeVisibleKey( + listState, + e.key === 'Home' ? 'first' : 'last', + ); + if (edgeKey != null) { + markKeyboardFocus(listState); + listState.selectionManager.setFocusedKey(edgeKey); + bumpFocus(); + } + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (!listState) return; + const focused = listState.selectionManager.focusedKey; + if (focused != null) { + e.preventDefault(); + commit(focused); + } + // Otherwise pass through (newline / form submit / tab navigation). + } else if (e.key === 'Escape') { + e.preventDefault(); + if (activeToken) { + setDismissedToken(activeToken.token); + } + } + }, + [shouldShowPopover, activeToken, commit, onKeyDown, listStateRef], + ); + + // ---- initial focus on the first option when the popover opens --------- + const focusInitAttemptsRef = useRef(0); + useLayoutEffect(() => { + if (!shouldShowPopover) return; + focusInitAttemptsRef.current = 0; + + const tick = () => { + if (!shouldShowPopover) return; + const listState = listStateRef.current; + if (!listState) { + focusInitAttemptsRef.current += 1; + if (focusInitAttemptsRef.current < 8) { + requestAnimationFrame(tick); + } + return; + } + if (listState.selectionManager.focusedKey == null) { + const first = getEdgeVisibleKey(listState, 'first'); + if (first != null) { + markKeyboardFocus(listState); + listState.selectionManager.setFocusedKey(first); + bumpFocus(); + } + } + }; + + requestAnimationFrame(() => requestAnimationFrame(tick)); + }, [shouldShowPopover]); + + // ---- keep virtual focus valid as the filtered list narrows ----------- + // When the user narrows the token, the previously focused option may drop + // out of the filtered list. Move focus to the first still-visible option so + // the highlight and any Enter/Tab commit stay in sync with what's shown. + useLayoutEffect(() => { + if (!shouldShowPopover) return; + const listState = listStateRef.current; + if (!listState) return; + + if (visibleFilteredKeys.length === 0) return; + + const focused = listState.selectionManager.focusedKey; + if (focused == null || !visibleFilteredKeys.includes(focused)) { + markKeyboardFocus(listState); + listState.selectionManager.setFocusedKey(visibleFilteredKeys[0]); + bumpFocus(); + } + }, [visibleFilteredKeys, shouldShowPopover]); + + // ---- composite focus (wrapper + portaled popover) --------------------- + const { compositeFocusProps } = useCompositeFocus({ + wrapperRef: wrapperRef as RefObject, + popoverRef: popoverRef as RefObject, + onFocus, + onBlur, + isDisabled, + }); + + // ---- aria-activedescendant ------------------------------------------- + const focusedKey = listStateRef.current?.selectionManager.focusedKey ?? null; + const listBoxId = `CommandTextAreaListBox-${commandTextAreaId}`; + + // ---- popover width ---------------------------------------------------- + // The popover is anchored to the caret (a point), so it must not be sized to + // the textarea. We use the same default as the shared `ListBoxPopover` / + // `Picker` popovers: a `30x` floor (240px), `max-content` preferred, `50vw` + // cap. The concrete floor keeps the virtualized ListBox from collapsing to a + // sliver (it has no intrinsic width), and `max-content` lets short option + // lists size to their content. The user's `overlayStyles` still win on top. + const popoverOverlayStyles: Styles = { + width: '30x max-content 50vw', + ...overlayStyles, + }; + + // ---- assemble input props --------------------------------------------- + const commandInputProps = mergeProps(inputProps, { + role: 'combobox', + 'aria-autocomplete': 'list', + 'aria-expanded': shouldShowPopover, + 'aria-haspopup': 'listbox', + 'aria-controls': shouldShowPopover ? listBoxId : undefined, + 'aria-activedescendant': + shouldShowPopover && focusedKey != null + ? `ListBoxItem-${focusedKey}` + : undefined, + onKeyDown: onKeyDownHandler, + onSelect: syncCaret, + onKeyUp: syncCaret, + onClick: syncCaret, + 'data-input-type': 'command-textarea', + }); + + // ---- render ----------------------------------------------------------- + const field = ( + + ); + + return ( + <> + {field} + } + positionTargetRef={caretAnchorRef as RefObject} + positionApiRef={positionApiRef} + popoverRef={popoverRef} + listBoxRef={listBoxRef} + direction={direction} + shouldFlip={shouldFlip} + overlayOffset={overlayOffset} + containerPadding={containerPadding} + listBoxId={listBoxId} + overlayStyles={popoverOverlayStyles} + listBoxStyles={listBoxStyles} + optionStyles={optionStyles} + optionHighlight={term} + sectionStyles={sectionStyles} + headingStyles={headingStyles} + isDisabled={isDisabled} + disabledKeys={disabledKeys} + items={items as any} + listStateRef={listStateRef} + ariaLabel={ + (props as any)['aria-label'] || + (typeof otherProps.label === 'string' ? otherProps.label : 'Commands') + } + compositeFocusProps={compositeFocusProps} + filter={filterFn} + onClose={() => { + if (activeToken) setDismissedToken(activeToken.token); + }} + onSelectionChange={(selection) => { + const key = Array.isArray(selection) ? selection[0] : selection; + if (key != null) commit(key); + }} + > + {children as any} + + + ); +} + +const _CommandTextArea = forwardRef(CommandTextArea) as unknown as (( + props: CubeCommandTextAreaProps & { + ref?: ForwardedRef; + }, +) => ReactElement) & { + Item: typeof Item; + Section: typeof BaseSection; +}; + +Object.assign(_CommandTextArea, { + Item, + Section: BaseSection, + displayName: 'CommandTextArea', +}); + +Object.defineProperty(_CommandTextArea, 'cubeInputType', { + value: 'CommandTextArea', + enumerable: false, + configurable: false, +}); + +export { _CommandTextArea as CommandTextArea }; diff --git a/src/components/fields/CommandTextArea/caretPosition.ts b/src/components/fields/CommandTextArea/caretPosition.ts new file mode 100644 index 000000000..931aa3f36 --- /dev/null +++ b/src/components/fields/CommandTextArea/caretPosition.ts @@ -0,0 +1,126 @@ +/** + * Computes the caret's position for a `