From 44be0710dbab1d2797b698d2ecfd29a98cac7837 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 1 Jul 2026 15:51:39 +0200 Subject: [PATCH 1/7] feat(CommandTextArea): add component --- src/components/fields/ComboBox/ComboBox.tsx | 440 +---------- .../CommandTextArea/CommandTextArea.docs.mdx | 138 ++++ .../CommandTextArea.stories.tsx | 209 ++++++ .../CommandTextArea/CommandTextArea.test.tsx | 212 ++++++ .../CommandTextArea/CommandTextArea.tsx | 709 ++++++++++++++++++ .../fields/CommandTextArea/index.ts | 7 + .../fields/ListBoxPopover/ListBoxPopover.tsx | 240 ++++++ src/components/fields/ListBoxPopover/index.ts | 21 + .../fields/ListBoxPopover/listNavigation.ts | 156 ++++ .../ListBoxPopover/useCompositeFocus.ts | 86 +++ src/components/fields/index.ts | 1 + 11 files changed, 1804 insertions(+), 415 deletions(-) create mode 100644 src/components/fields/CommandTextArea/CommandTextArea.docs.mdx create mode 100644 src/components/fields/CommandTextArea/CommandTextArea.stories.tsx create mode 100644 src/components/fields/CommandTextArea/CommandTextArea.test.tsx create mode 100644 src/components/fields/CommandTextArea/CommandTextArea.tsx create mode 100644 src/components/fields/CommandTextArea/index.ts create mode 100644 src/components/fields/ListBoxPopover/ListBoxPopover.tsx create mode 100644 src/components/fields/ListBoxPopover/index.ts create mode 100644 src/components/fields/ListBoxPopover/listNavigation.ts create mode 100644 src/components/fields/ListBoxPopover/useCompositeFocus.ts 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..ccf72e7a9 --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx @@ -0,0 +1,138 @@ +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 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. + +### 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 conversation + Show help + +``` + +### Mention trigger (anywhere in the text) + +```jsx + + John Doe + Jane Roe + +``` + +### Data-driven with sections + +```jsx + + {(item) => {item.label}} + +``` + +## 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..b290208b8 --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx @@ -0,0 +1,209 @@ +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', children: 'Clear conversation', textValue: '/clear' }, + { key: '/help', children: 'Show help', textValue: '/help' }, + { key: '/share', children: 'Share conversation', textValue: '/share' }, + { key: '/summarize', children: 'Summarize thread', textValue: '/summarize' }, + { key: '/settings', children: 'Open settings', textValue: '/settings' }, +]; + +export const Default: StoryFn = (props) => ( + + {commands.map((c) => ( + + {c.children} + + ))} + +); + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState(''); + return ( + { + console.log('picked command', key); + }} + > + {commands.map((c) => ( + + {c.children} + + ))} + + ); +}; + +export const MentionTrigger: StoryFn = (props) => ( + + {[ + { key: '@john', children: 'John Doe', textValue: '@john' }, + { key: '@jane', children: 'Jane Roe', textValue: '@jane' }, + { key: '@alex', children: 'Alex Kim', textValue: '@alex' }, + ].map((c) => ( + + {c.children} + + ))} + +); + +export const WithSections: StoryFn = (props) => ( + + + + Clear conversation + + + Share conversation + + + + + Show help + + + Open settings + + + +); + +export const AutoSize: StoryFn = (props) => ( + + {commands.map((c) => ( + + {c.children} + + ))} + +); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.test.tsx b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx new file mode 100644 index 000000000..64dda274a --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx @@ -0,0 +1,212 @@ +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', children: 'Clear conversation', textValue: '/clear' }, + { key: '/help', children: 'Show help', textValue: '/help' }, + { key: '/share', children: 'Share conversation', textValue: '/share' }, + { key: '/summarize', children: 'Summarize thread', textValue: '/summarize' }, +]; + +const commandItems = commands.map((c) => ( + + {c.children} + +)); + +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('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('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'); + }); +}); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.tsx b/src/components/fields/CommandTextArea/CommandTextArea.tsx new file mode 100644 index 000000000..8c566d47d --- /dev/null +++ b/src/components/fields/CommandTextArea/CommandTextArea.tsx @@ -0,0 +1,709 @@ +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 { + filterCollectionNodes, + getEdgeVisibleKey, + getNextVisibleKey, + ListBoxPopover, + ListStateLike, + markKeyboardFocus, + TextFilterFn, + useCompositeFocus, +} from '../ListBoxPopover'; +import { + CubeTextInputBaseProps, + TextInputBase, +} from '../TextInput/TextInputBase'; + +// --------------------------------------------------------------------------- +// 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 index 0 of the input + * (whole-message 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) { + // Whole-message command: token must start at index 0 and contain no + // whitespace up to the caret. + if (value[0] !== ch) continue; + // No whitespace allowed within the token. + const wsIndex = before.slice(1).search(/\s/); + if (wsIndex !== -1) continue; + // The rest of the input (after caret) must also not yet contain a + // whitespace boundary that would mean the command is "finished" — but + // since the caret is the typing position, the token runs from 0 to the + // first whitespace at/after... we keep it simple: token = value up to + // caret, provided no whitespace in `before` past the trigger char and + // the char after caret (if any) is not whitespace-only trailing. + const token = before; + if (!token) continue; + return { trigger, token, start: 0, 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, + 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 listStateRef = (propsListStateRef ?? + useRef(null)) 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) ?? '', + ); + 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, caret, triggers), + [effectiveValue, caret, triggers], + ); + + const term = activeToken?.token ?? ''; + + // ---- 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), + [term, textFilterFn], + ); + + // Count filtered options (flatten sections) to decide whether to show. + const filteredCount = useMemo(() => { + if (!activeToken) return 0; + const filtered = filterFn(localCollectionState.collection); + let count = 0; + for (const node of filtered as Iterable) { + if (node.type === 'section' && node.childNodes) { + for (const child of node.childNodes) { + if (child.type === 'item') count++; + } + } else if (node.type === 'item') { + count++; + } + } + return count; + }, [activeToken, filterFn, localCollectionState.collection]); + + // ---- 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; + + // ---- 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(() => { + if (pendingCaretRef.current != null && inputRef.current) { + const pos = pendingCaretRef.current; + pendingCaretRef.current = null; + inputRef.current.focus(); + inputRef.current.setSelectionRange(pos, pos); + setCaret(pos); + } + }, [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; + 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]); + + // ---- 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 virtualized ListBox has no intrinsic width, so an overlay sized to + // `max-content` collapses to a sliver. We give the popover a concrete + // default width (independent of the textarea) and a real-pixel min-width + // derived from the textarea wrapper, so it is never narrower than the + // input. The user's `overlayStyles` still win on top. + const anchorWidth = wrapperRef.current?.offsetWidth ?? 0; + const popoverOverlayStyles: Styles = { + width: '40x', + maxWidth: '50vw', + minWidth: anchorWidth ? `${anchorWidth}px` : '40x', + ...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} + } + popoverRef={popoverRef} + listBoxRef={listBoxRef} + direction={direction} + shouldFlip={shouldFlip} + overlayOffset={overlayOffset} + containerPadding={containerPadding} + listBoxId={listBoxId} + overlayStyles={popoverOverlayStyles} + listBoxStyles={listBoxStyles} + optionStyles={optionStyles} + 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 ( + props: CubeCommandTextAreaProps & { + ref?: ForwardedRef; + }, +) => ReactElement; + +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/index.ts b/src/components/fields/CommandTextArea/index.ts new file mode 100644 index 000000000..67293f185 --- /dev/null +++ b/src/components/fields/CommandTextArea/index.ts @@ -0,0 +1,7 @@ +export { CommandTextArea } from './CommandTextArea'; +export type { + CubeCommandTextAreaProps, + CommandTrigger, + ActiveToken, + CommandTextAreaFilterFn, +} from './CommandTextArea'; diff --git a/src/components/fields/ListBoxPopover/ListBoxPopover.tsx b/src/components/fields/ListBoxPopover/ListBoxPopover.tsx new file mode 100644 index 000000000..a7084fb00 --- /dev/null +++ b/src/components/fields/ListBoxPopover/ListBoxPopover.tsx @@ -0,0 +1,240 @@ +import { Key } from '@react-types/shared'; +import { Styles, tasty } from '@tenphi/tasty'; +import React, { ReactNode, RefObject } from 'react'; +import { useOverlay, useOverlayPosition } from 'react-aria'; + +import { mergeProps } from '../../../utils/react'; +import { DisplayTransition } from '../../helpers'; +import { Portal } from '../../portal'; +import { ListBox } from '../ListBox/ListBox'; + +export interface ListBoxPopoverProps { + isOpen: boolean; + triggerRef: RefObject; + popoverRef: RefObject; + listBoxRef: RefObject; + direction: 'bottom' | 'top'; + shouldFlip: boolean; + overlayOffset: number; + containerPadding: number; + /** Min width (px) for the overlay; usually the trigger's width. */ + comboBoxWidth?: number; + /** Stable id used for the ListBox element and aria-controls wiring. */ + listBoxId: string; + overlayStyles?: Styles; + listBoxStyles?: Styles; + optionStyles?: Styles; + sectionStyles?: Styles; + headingStyles?: Styles; + selectedKey?: Key | null; + isDisabled?: boolean; + disabledKeys?: Iterable; + items?: Iterable; + children: ReactNode; + /** Ref through which the ListBox exposes its internal list state. */ + 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; + }; + /** Collection node filter applied to the ListBox. */ + filter?: (nodes: Iterable) => Iterable; + size?: 'small' | 'medium' | 'large' | (string & {}); +} + +const ListBoxPopoverWrapper = tasty({ + qa: 'ComboBoxOverlayWrapper', + styles: { + position: 'absolute', + zIndex: 1000, + }, +}); + +const ListBoxPopoverElement = 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 const ListBoxPopover = function ListBoxPopover( + props: ListBoxPopoverProps, +) { + const { + isOpen, + triggerRef, + popoverRef, + listBoxRef, + direction, + shouldFlip, + overlayOffset, + containerPadding, + comboBoxWidth, + listBoxId, + overlayStyles, + listBoxStyles, + optionStyles, + sectionStyles, + headingStyles, + selectedKey, + isDisabled, + disabledKeys, + items, + children, + listStateRef, + onSelectionChange, + onClose, + label, + ariaLabel, + compositeFocusProps, + filter, + size = 'medium', + } = props; + + const mergedPopoverRef = popoverRef; + + // Overlay positioning + const { overlayProps: overlayPositionProps, placement } = useOverlayPosition({ + targetRef: triggerRef as any, + overlayRef: mergedPopoverRef 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; + }, + }, + mergedPopoverRef 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}; +}; diff --git a/src/components/fields/ListBoxPopover/index.ts b/src/components/fields/ListBoxPopover/index.ts new file mode 100644 index 000000000..1ae30d4af --- /dev/null +++ b/src/components/fields/ListBoxPopover/index.ts @@ -0,0 +1,21 @@ +export { ListBoxPopover } from './ListBoxPopover'; +export type { ListBoxPopoverProps } from './ListBoxPopover'; +export { useCompositeFocus } from './useCompositeFocus'; +export type { + UseCompositeFocusProps, + UseCompositeFocusReturn, +} from './useCompositeFocus'; +export { + collectVisibleKeys, + getVisibleKeys, + getNextVisibleKey, + getEdgeVisibleKey, + markKeyboardFocus, + filterCollectionNodes, +} from './listNavigation'; +export type { + ListStateLike, + NavigationDirection, + NavigationEdge, + TextFilterFn, +} from './listNavigation'; diff --git a/src/components/fields/ListBoxPopover/listNavigation.ts b/src/components/fields/ListBoxPopover/listNavigation.ts new file mode 100644 index 000000000..97035ea6a --- /dev/null +++ b/src/components/fields/ListBoxPopover/listNavigation.ts @@ -0,0 +1,156 @@ +import { Key } from '@react-types/shared'; + +/** + * Minimal view of a React Stately list state. Kept structural (no imports from + * `react-stately`) so the helpers can be used from any collection-backed + * component without dragging extra types in. + */ +export interface ListStateLike { + selectionManager: { + focusedKey: Key | null; + setFocusedKey: (key: Key | null) => void; + }; + collection: Iterable; + disabledKeys?: Set; + lastFocusSourceRef?: { current: 'keyboard' | 'mouse' | 'other' }; +} + +export type NavigationDirection = 1 | -1; +export type NavigationEdge = 'first' | 'last'; + +/** + * Walks a collection (possibly nested under sections) and collects the keys of + * selectable items, skipping disabled ones. Mirrors the inline walker that + * used to live in `useComboBoxKeyboard`. + */ +export function collectVisibleKeys( + nodes: Iterable, + out: Key[], + disabledKeys?: Set, +): void { + 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, disabledKeys); + } + } +} + +/** Convenience: returns the flat list of selectable keys for a list state. */ +export function getVisibleKeys(listState: ListStateLike): Key[] { + const keys: Key[] = []; + collectVisibleKeys( + listState.collection, + keys, + listState.disabledKeys as Set | undefined, + ); + return keys; +} + +/** + * Computes the next key to focus when navigating with ArrowUp/ArrowDown. + * + * - If `fromKey` is omitted, reads `selectionManager.focusedKey`. + * - When nothing is focused (or the focused key is no longer visible), falls + * back to the first/last visible key depending on `direction`. + * - When the current key is the boundary, either returns `null` (default) or + * wraps to the opposite edge when `opts.wrap` is true. + */ +export function getNextVisibleKey( + listState: ListStateLike, + direction: NavigationDirection, + opts?: { wrap?: boolean; fromKey?: Key | null }, +): Key | null { + const visibleKeys = getVisibleKeys(listState); + if (visibleKeys.length === 0) return null; + + const isForward = direction === 1; + const currentKey = opts?.fromKey ?? listState.selectionManager.focusedKey; + + if (currentKey == null) { + return isForward ? visibleKeys[0] : visibleKeys[visibleKeys.length - 1]; + } + + const currentIndex = visibleKeys.indexOf(currentKey); + + if (currentIndex === -1) { + // Focused item was filtered out — restart from the matching edge. + return isForward ? visibleKeys[0] : visibleKeys[visibleKeys.length - 1]; + } + + const newIndex = currentIndex + direction; + if (newIndex >= 0 && newIndex < visibleKeys.length) { + return visibleKeys[newIndex]; + } + + if (opts?.wrap) { + return isForward ? visibleKeys[0] : visibleKeys[visibleKeys.length - 1]; + } + + return null; +} + +/** Returns the first or last selectable key. */ +export function getEdgeVisibleKey( + listState: ListStateLike, + edge: NavigationEdge, +): Key | null { + const visibleKeys = getVisibleKeys(listState); + if (visibleKeys.length === 0) return null; + return edge === 'first' + ? visibleKeys[0] + : visibleKeys[visibleKeys.length - 1]; +} + +/** + * Marks a focus change as keyboard-driven so the underlying ListBox scrolls the + * highlighted option into view. No-op when the list state doesn't expose a + * `lastFocusSourceRef`. + */ +export function markKeyboardFocus(listState: ListStateLike): void { + if (listState.lastFocusSourceRef) { + listState.lastFocusSourceRef.current = 'keyboard'; + } +} + +export type TextFilterFn = (textValue: string, inputValue: string) => boolean; + +/** + * Filters collection nodes by a search term while preserving section structure. + * Sections whose children all fail the filter are dropped. Extracted verbatim + * from the former `useComboBoxFiltering` inline filter. + */ +export function filterCollectionNodes( + nodes: Iterable, + term: string, + textFilterFn: TextFilterFn, +): Iterable { + if (!term) { + return nodes; + } + + 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); +} diff --git a/src/components/fields/ListBoxPopover/useCompositeFocus.ts b/src/components/fields/ListBoxPopover/useCompositeFocus.ts new file mode 100644 index 000000000..30b46d1d2 --- /dev/null +++ b/src/components/fields/ListBoxPopover/useCompositeFocus.ts @@ -0,0 +1,86 @@ +import React, { RefObject, useCallback, useEffect, useRef } from 'react'; + +export interface UseCompositeFocusProps { + wrapperRef: RefObject; + popoverRef: RefObject; + onFocus?: () => void; + onBlur?: () => void; + isDisabled?: boolean; +} + +export interface UseCompositeFocusReturn { + compositeFocusProps: { + onFocus: (e: React.FocusEvent) => void; + onBlur: (e: React.FocusEvent) => void; + }; +} + +/** + * Tracks focus across a wrapper element and its (portaled) popover as a single + * logical focus scope. Fires `onFocus` when focus enters either and `onBlur` + * when it leaves both — essential for components whose overlay is portaled, so + * that clicking an option does not look like a blur of the whole component. + * + * Focus checks are deferred to the next animation frame to tolerate the + * synchronous focus shuffles React Aria and portals perform on selection. + */ +export 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, + }, + }; +} diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 955f03dd4..d78a39037 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -4,6 +4,7 @@ export * from './PasswordInput/PasswordInput'; export * from './NumberInput/NumberInput'; export * from './FileInput/FileInput'; export * from './TextArea'; +export * from './CommandTextArea'; export * from './Checkbox'; export * from './DatePicker'; export * from './RadioGroup'; From 4fe7d814a01490e93fbf3e522524d9d205880997 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 1 Jul 2026 16:43:58 +0200 Subject: [PATCH 2/7] feat(CommandTextArea): add component * 2 --- src/components/content/Item/Item.tsx | 15 ++- .../CommandTextArea/CommandTextArea.docs.mdx | 25 +++- .../CommandTextArea.stories.tsx | 80 ++++++++---- .../CommandTextArea/CommandTextArea.test.tsx | 117 +++++++++++++++++- .../CommandTextArea/CommandTextArea.tsx | 20 ++- src/components/fields/ListBox/ListBox.tsx | 16 +++ .../fields/ListBoxPopover/ListBoxPopover.tsx | 4 + .../fields/ListBoxPopover/listNavigation.ts | 43 ++++++- 8 files changed, 278 insertions(+), 42 deletions(-) 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/CommandTextArea/CommandTextArea.docs.mdx b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx index ccf72e7a9..268b17544 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx +++ b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx @@ -48,6 +48,21 @@ 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. + ### Base Properties Supports [Base properties](/docs/getting-started-base-properties--docs). @@ -86,8 +101,8 @@ These properties allow direct style application without using the `styles` prop: placeholder="Type / to see commands…" onCommand={(key) => console.log('picked', key)} > - Clear conversation - Show help + /clear + /help
``` @@ -98,8 +113,8 @@ These properties allow direct style application without using the `styles` prop: label="Message" triggers={[{ char: '@', atLineStart: false }]} > - John Doe - Jane Roe + @john + @jane ``` @@ -107,7 +122,7 @@ These properties allow direct style application without using the `styles` prop: ```jsx - {(item) => {item.label}} + {(item) => {item.key}} ``` diff --git a/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx index b290208b8..21ebd4511 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx @@ -102,11 +102,11 @@ export default { }; const commands = [ - { key: '/clear', children: 'Clear conversation', textValue: '/clear' }, - { key: '/help', children: 'Show help', textValue: '/help' }, - { key: '/share', children: 'Share conversation', textValue: '/share' }, - { key: '/summarize', children: 'Summarize thread', textValue: '/summarize' }, - { key: '/settings', children: 'Open settings', textValue: '/settings' }, + { 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) => ( @@ -117,8 +117,12 @@ export const Default: StoryFn = (props) => ( {...props} > {commands.map((c) => ( - - {c.children} + + {c.key} ))} @@ -138,8 +142,12 @@ export const Controlled: StoryFn = () => { }} > {commands.map((c) => ( - - {c.children} + + {c.key} ))} @@ -155,12 +163,16 @@ export const MentionTrigger: StoryFn = (props) => ( {...props} > {[ - { key: '@john', children: 'John Doe', textValue: '@john' }, - { key: '@jane', children: 'Jane Roe', textValue: '@jane' }, - { key: '@alex', children: 'Alex Kim', textValue: '@alex' }, + { key: '@john', description: 'John Doe' }, + { key: '@jane', description: 'Jane Roe' }, + { key: '@alex', description: 'Alex Kim' }, ].map((c) => ( - - {c.children} + + {c.key} ))} @@ -174,19 +186,35 @@ export const WithSections: StoryFn = (props) => ( {...props} > - - Clear conversation + + /clear - - Share conversation + + /share - - Show help + + /help - - Open settings + + /settings @@ -201,8 +229,12 @@ export const AutoSize: StoryFn = (props) => ( {...props} > {commands.map((c) => ( - - {c.children} + + {c.key} ))} diff --git a/src/components/fields/CommandTextArea/CommandTextArea.test.tsx b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx index 64dda274a..4ce5861fa 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.test.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx @@ -7,15 +7,19 @@ import { CommandTextArea } from './CommandTextArea'; vi.mock('../../../_internal/hooks/use-warn'); const commands = [ - { key: '/clear', children: 'Clear conversation', textValue: '/clear' }, - { key: '/help', children: 'Show help', textValue: '/help' }, - { key: '/share', children: 'Share conversation', textValue: '/share' }, - { key: '/summarize', children: 'Summarize thread', textValue: '/summarize' }, + { 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.children} + {c.key} )); @@ -72,6 +76,109 @@ describe('', () => { }); }); + 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( diff --git a/src/components/fields/CommandTextArea/CommandTextArea.tsx b/src/components/fields/CommandTextArea/CommandTextArea.tsx index 8c566d47d..7f4aecee1 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.tsx @@ -316,7 +316,17 @@ function CommandTextArea( [effectiveValue, caret, triggers], ); - const term = activeToken?.token ?? ''; + // 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' }); @@ -662,6 +672,7 @@ function CommandTextArea( overlayStyles={popoverOverlayStyles} listBoxStyles={listBoxStyles} optionStyles={optionStyles} + optionHighlight={term} sectionStyles={sectionStyles} headingStyles={headingStyles} isDisabled={isDisabled} @@ -688,11 +699,14 @@ function CommandTextArea( ); } -const _CommandTextArea = forwardRef(CommandTextArea) as ( +const _CommandTextArea = forwardRef(CommandTextArea) as unknown as (( props: CubeCommandTextAreaProps & { ref?: ForwardedRef; }, -) => ReactElement; +) => ReactElement) & { + Item: typeof Item; + Section: typeof BaseSection; +}; Object.assign(_CommandTextArea, { Item, diff --git a/src/components/fields/ListBox/ListBox.tsx b/src/components/fields/ListBox/ListBox.tsx index b8334f5a8..f850738ce 100644 --- a/src/components/fields/ListBox/ListBox.tsx +++ b/src/components/fields/ListBox/ListBox.tsx @@ -291,6 +291,11 @@ export interface CubeListBoxProps listStyles?: Styles; /** Custom styles for individual options */ optionStyles?: Styles; + /** + * Substring to highlight within each option's (text) children. Typically the + * current search query. Matching is case-insensitive. + */ + optionHighlight?: string; /** Custom styles for section containers */ sectionStyles?: Styles; /** Custom styles for section headings */ @@ -598,6 +603,7 @@ export const ListBox = forwardRef(function ListBox( isDisabled, listStyles, optionStyles, + optionHighlight, sectionStyles, headingStyles, listRef, @@ -1057,6 +1063,7 @@ export const ListBox = forwardRef(function ListBox( item={item} state={listState} styles={optionStyles} + highlight={optionHighlight} isParentDisabled={isDisabled} validationState={validationState} focusOnHover={focusOnHover} @@ -1100,6 +1107,7 @@ export const ListBox = forwardRef(function ListBox( item={item} state={listState} styles={optionStyles} + highlight={optionHighlight} isParentDisabled={isDisabled} validationState={validationState} focusOnHover={focusOnHover} @@ -1142,6 +1150,7 @@ export const ListBox = forwardRef(function ListBox( item={item} state={listState} optionStyles={optionStyles} + optionHighlight={optionHighlight} headingStyles={headingStyles} sectionStyles={sectionStyles} isParentDisabled={isDisabled} @@ -1163,6 +1172,7 @@ export const ListBox = forwardRef(function ListBox( item={item} state={listState} styles={optionStyles} + highlight={optionHighlight} isParentDisabled={isDisabled} validationState={validationState} focusOnHover={focusOnHover} @@ -1206,6 +1216,7 @@ function Option({ item, state, styles, + highlight, isParentDisabled, validationState, focusOnHover = false, @@ -1222,6 +1233,7 @@ function Option({ item: any; state: any; styles?: Styles; + highlight?: string; isParentDisabled?: boolean; validationState?: any; focusOnHover?: boolean; @@ -1400,6 +1412,7 @@ function Option({ isSelected={isSelected} isDisabled={isDisabled} icon={effectiveIcon} + highlight={highlight} labelProps={labelProps} descriptionProps={descriptionProps} styles={mergeStyles(styles, filteredItemProps.styles)} @@ -1479,6 +1492,7 @@ interface ListBoxSectionProps { item: any; state: any; optionStyles?: Styles; + optionHighlight?: string; headingStyles?: Styles; sectionStyles?: Styles; isParentDisabled?: boolean; @@ -1495,6 +1509,7 @@ function ListBoxSection(props: ListBoxSectionProps) { item, state, optionStyles, + optionHighlight, headingStyles, sectionStyles, isParentDisabled, @@ -1532,6 +1547,7 @@ function ListBoxSection(props: ListBoxSectionProps) { item={node} state={state} styles={optionStyles} + highlight={optionHighlight} isParentDisabled={isParentDisabled} validationState={validationState} focusOnHover={focusOnHover} diff --git a/src/components/fields/ListBoxPopover/ListBoxPopover.tsx b/src/components/fields/ListBoxPopover/ListBoxPopover.tsx index a7084fb00..e244a11cd 100644 --- a/src/components/fields/ListBoxPopover/ListBoxPopover.tsx +++ b/src/components/fields/ListBoxPopover/ListBoxPopover.tsx @@ -24,6 +24,8 @@ export interface ListBoxPopoverProps { overlayStyles?: Styles; listBoxStyles?: Styles; optionStyles?: Styles; + /** Substring to highlight within each option's (text) children. */ + optionHighlight?: string; sectionStyles?: Styles; headingStyles?: Styles; selectedKey?: Key | null; @@ -116,6 +118,7 @@ export const ListBoxPopover = function ListBoxPopover( overlayStyles, listBoxStyles, optionStyles, + optionHighlight, sectionStyles, headingStyles, selectedKey, @@ -221,6 +224,7 @@ export const ListBoxPopover = function ListBoxPopover( filter={filter} styles={listBoxStyles} optionStyles={optionStyles} + optionHighlight={optionHighlight} sectionStyles={sectionStyles} headingStyles={headingStyles} stateRef={listStateRef} diff --git a/src/components/fields/ListBoxPopover/listNavigation.ts b/src/components/fields/ListBoxPopover/listNavigation.ts index 97035ea6a..62535b3ff 100644 --- a/src/components/fields/ListBoxPopover/listNavigation.ts +++ b/src/components/fields/ListBoxPopover/listNavigation.ts @@ -118,10 +118,45 @@ export function markKeyboardFocus(listState: ListStateLike): void { export type TextFilterFn = (textValue: string, inputValue: string) => boolean; +/** + * Collects the strings a node can be matched against: its `textValue` plus any + * plain-text `children` and `description` props. Non-string children/description + * (e.g. React elements) are ignored since we can't reliably read their text. + */ +function getSearchableStrings(node: any): string[] { + const strings: string[] = []; + + if (node.textValue) { + strings.push(node.textValue); + } + + const props = node.props; + if (props) { + if (typeof props.children === 'string') { + strings.push(props.children); + } + if (typeof props.description === 'string') { + strings.push(props.description); + } + } + + return strings.length > 0 ? strings : [node.textValue || '']; +} + +/** True when the term matches any of the node's searchable strings. */ +function nodeMatchesTerm( + node: any, + term: string, + textFilterFn: TextFilterFn, +): boolean { + return getSearchableStrings(node).some((text) => textFilterFn(text, term)); +} + /** * Filters collection nodes by a search term while preserving section structure. - * Sections whose children all fail the filter are dropped. Extracted verbatim - * from the former `useComboBoxFiltering` inline filter. + * A node matches when the term matches its `textValue`, its plain-text + * `children`, or its plain-text `description`. Sections whose children all fail + * the filter are dropped. */ export function filterCollectionNodes( nodes: Iterable, @@ -136,7 +171,7 @@ export function filterCollectionNodes( .map((node: any) => { if (node.type === 'section' && node.childNodes) { const filteredChildren = [...node.childNodes].filter((child: any) => - textFilterFn(child.textValue || '', term), + nodeMatchesTerm(child, term, textFilterFn), ); if (filteredChildren.length === 0) { @@ -150,7 +185,7 @@ export function filterCollectionNodes( }; } - return textFilterFn(node.textValue || '', term) ? node : null; + return nodeMatchesTerm(node, term, textFilterFn) ? node : null; }) .filter(Boolean); } From 69b395f825495afbd4f45ac1e1208e06909b04c7 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 1 Jul 2026 17:44:22 +0200 Subject: [PATCH 3/7] feat(CommandTextArea): add component * 3 --- .../CommandTextArea/CommandTextArea.docs.mdx | 8 ++ .../CommandTextArea.stories.tsx | 21 +++ .../CommandTextArea/CommandTextArea.test.tsx | 24 ++++ .../CommandTextArea/CommandTextArea.tsx | 24 +++- .../fields/CommandTextArea/caretPosition.ts | 126 ++++++++++++++++++ .../fields/CommandTextArea/useCaretAnchor.ts | 99 ++++++++++++++ .../fields/ListBoxPopover/ListBoxPopover.tsx | 39 +++++- 7 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 src/components/fields/CommandTextArea/caretPosition.ts create mode 100644 src/components/fields/CommandTextArea/useCaretAnchor.ts diff --git a/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx index 268b17544..4471ce5a3 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx +++ b/src/components/fields/CommandTextArea/CommandTextArea.docs.mdx @@ -63,6 +63,14 @@ 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). diff --git a/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx index 21ebd4511..e82631880 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.stories.tsx @@ -239,3 +239,24 @@ export const AutoSize: StoryFn = (props) => ( ))} ); + +export const Multiline: StoryFn = (props) => ( + + {commands.map((c) => ( + + {c.key} + + ))} + +); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.test.tsx b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx index 4ce5861fa..faad07f4e 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.test.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.test.tsx @@ -316,4 +316,28 @@ describe('', () => { }); 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()); + }); }); diff --git a/src/components/fields/CommandTextArea/CommandTextArea.tsx b/src/components/fields/CommandTextArea/CommandTextArea.tsx index 7f4aecee1..a2c09bfa8 100644 --- a/src/components/fields/CommandTextArea/CommandTextArea.tsx +++ b/src/components/fields/CommandTextArea/CommandTextArea.tsx @@ -41,6 +41,8 @@ import { TextInputBase, } from '../TextInput/TextInputBase'; +import { useCaretAnchor } from './useCaretAnchor'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -381,6 +383,17 @@ function CommandTextArea( 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; @@ -608,14 +621,13 @@ function CommandTextArea( // ---- popover width ---------------------------------------------------- // The virtualized ListBox has no intrinsic width, so an overlay sized to // `max-content` collapses to a sliver. We give the popover a concrete - // default width (independent of the textarea) and a real-pixel min-width - // derived from the textarea wrapper, so it is never narrower than the - // input. The user's `overlayStyles` still win on top. - const anchorWidth = wrapperRef.current?.offsetWidth ?? 0; + // default width (independent of the textarea). Because the popover is now + // anchored to the caret (a point) rather than the textarea edge, it should + // not be forced to the textarea's full width — react-aria clamps it to the + // viewport. The user's `overlayStyles` still win on top. const popoverOverlayStyles: Styles = { width: '40x', maxWidth: '50vw', - minWidth: anchorWidth ? `${anchorWidth}px` : '40x', ...overlayStyles, }; @@ -662,6 +674,8 @@ function CommandTextArea( } + positionTargetRef={caretAnchorRef as RefObject} + positionApiRef={positionApiRef} popoverRef={popoverRef} listBoxRef={listBoxRef} direction={direction} 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 `