From 39166a6dfa641c9d0bcecc4158d2d0dd76038a4c Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 13 Nov 2025 11:27:48 +0100 Subject: [PATCH 1/6] Prototype mentions --- pages/prompt-input/simple.page.tsx | 298 +++++++++++++++++++---------- src/prompt-input/interfaces.ts | 7 +- src/prompt-input/internal.tsx | 179 +++++++++++++---- src/prompt-input/styles.scss | 28 ++- src/prompt-input/utils.tsx | 83 ++++++++ 5 files changed, 443 insertions(+), 152 deletions(-) create mode 100644 src/prompt-input/utils.tsx diff --git a/pages/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx index 0b42d43ce6..01fbf42093 100644 --- a/pages/prompt-input/simple.page.tsx +++ b/pages/prompt-input/simple.page.tsx @@ -33,6 +33,10 @@ type DemoContext = React.Context< hasSecondaryActions: boolean; hasPrimaryActions: boolean; hasInfiniteMaxRows: boolean; + disableActionButton: boolean; + disableBrowserAutocorrect: boolean; + enableSpellcheck: boolean; + hasName: boolean; }> >; @@ -40,8 +44,8 @@ const placeholderText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; export default function PromptInputPage() { - const [textareaValue, setTextareaValue] = useState(''); - const [valueInSplitPanel, setValueInSplitPanel] = useState(''); + const [textareaValue, setTextareaValue] = useState(''); + const [valueInSplitPanel, setValueInSplitPanel] = useState(''); const [files, setFiles] = useState([]); const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); @@ -55,6 +59,10 @@ export default function PromptInputPage() { hasSecondaryContent, hasPrimaryActions, hasInfiniteMaxRows, + disableActionButton, + disableBrowserAutocorrect, + enableSpellcheck, + hasName, } = urlParams; const [items, setItems] = React.useState([ @@ -163,6 +171,46 @@ export default function PromptInputPage() { > Infinite max rows + + setUrlParams({ + disableActionButton: !disableActionButton, + }) + } + > + Disable action button + + + setUrlParams({ + disableBrowserAutocorrect: !disableBrowserAutocorrect, + }) + } + > + Disable browser autocorrect + + + setUrlParams({ + enableSpellcheck: !enableSpellcheck, + }) + } + > + Enable spellcheck + + + setUrlParams({ + hasName: !hasName, + }) + } + > + Has name attribute (for forms) + - - MAX_CHARS || isInvalid) && 'The query has too many characters.'} - warningText={hasWarning && 'This input has a warning'} - constraintText={ - <> - This service is subject to some policy. Character count: {textareaValue.length}/{MAX_CHARS} - - } - label={User prompt} - i18nStrings={{ errorIconAriaLabel: 'Error' }} - > - setTextareaValue(event.detail.value)} - onAction={event => window.alert(`Submitted the following: ${event.detail.value}`)} - placeholder="Ask a question" - maxRows={hasInfiniteMaxRows ? -1 : 4} - disabled={isDisabled} - readOnly={isReadOnly} - invalid={isInvalid || textareaValue.length > MAX_CHARS} - warning={hasWarning} - ref={ref} - disableSecondaryActionsPaddings={true} - customPrimaryAction={ - hasPrimaryActions ? ( - - ) : undefined - } - secondaryActions={ - hasSecondaryActions ? ( - - detail.id.includes('files') && setFiles(detail.files)} - items={[ - { - type: 'icon-file-input', - id: 'files', - text: 'Upload files', - multiple: true, - }, - { - type: 'icon-button', - id: 'expand', - iconName: 'expand', - text: 'Go full page', - disabled: isDisabled || isReadOnly, - }, - { - type: 'icon-button', - id: 'remove', - iconName: 'remove', - text: 'Remove', - disabled: isDisabled || isReadOnly, - }, - ]} - variant="icon" - /> - - ) : undefined - } - secondaryContent={ - hasSecondaryContent && files.length > 0 ? ( - ({ - file, - }))} - showFileThumbnail={true} - onDismiss={onDismiss} - i18nStrings={i18nStrings} - alignment="horizontal" - /> - ) : undefined +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const data: Record = {}; + const entries: Array<{ name: string; value: string }> = []; + + formData.forEach((value, key) => { + data[key] = String(value); + entries.push({ name: key, value: String(value) }); + }); + + console.log('FORM SUBMITTED:', { + formData: data, + entries, + }); + }} + > + + MAX_CHARS || isInvalid) && 'The query has too many characters.'} + warningText={hasWarning && 'This input has a warning'} + constraintText={ + <> + This service is subject to some policy. Character count: {textareaValue.length}/{MAX_CHARS} + } - /> - -
- + label={User prompt} + i18nStrings={{ errorIconAriaLabel: 'Error' }} + > +
{ + // Prevent form submission from secondary action buttons + e.preventDefault(); + e.stopPropagation(); + }} + > + setTextareaValue(event.detail.value)} + onAction={event => window.alert(`Submitted the following: ${event.detail.value}`)} + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || textareaValue.length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + disableActionButton={disableActionButton} + disableBrowserAutocorrect={disableBrowserAutocorrect} + spellcheck={enableSpellcheck} + name={hasName ? 'user-prompt' : undefined} + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + + detail.id.includes('files') && setFiles(detail.files)} + onItemClick={({ detail }) => { + if (detail.id === 'bug') { + // Add @debug mention - will be automatically converted to Token + setTextareaValue(prev => `${prev} @debug `); + } + }} + items={[ + { + type: 'icon-file-input', + id: 'files', + text: 'Upload files', + multiple: true, + }, + { + type: 'icon-button', + id: 'bug', + iconName: 'bug', + text: 'Add debug token', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Go full page', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'remove', + iconName: 'remove', + text: 'Remove', + disabled: isDisabled || isReadOnly, + }, + ]} + variant="icon" + /> + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> +
+ +
+ +
} diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 42d388f778..f9699e4d79 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -18,13 +18,18 @@ import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps - extends Omit, + extends Omit, InputKeyEvents, InputAutoCorrect, InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { + /** + * Specifies the content of the prompt input. + */ + value?: string; + /** * Called whenever a user clicks the action button or presses the "Enter" key. * The event `detail` contains the current value of the field. diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index aa76b69f98..38908eae5c 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import ReactDOM from 'react-dom'; import clsx from 'clsx'; import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; @@ -14,7 +15,7 @@ import * as tokens from '../internal/generated/styles/tokens'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { SomeRequired } from '../internal/types'; -import WithNativeAttributes from '../internal/utils/with-native-attributes'; +import Token from '../token/internal'; import { PromptInputProps } from './interfaces'; import { getPromptInputStyles } from './styles'; @@ -57,7 +58,6 @@ const InternalPromptInput = React.forwardRef( secondaryContent, disableSecondaryActionsPaddings, disableSecondaryContentPaddings, - nativeTextareaAttributes, style, __internalRootRef, ...rest @@ -68,10 +68,16 @@ const InternalPromptInput = React.forwardRef( const baseProps = getBaseProps(rest); - const textareaRef = useRef(null); + // Get the current text value (will be updated on each render) + const editableElementRef = useRef(null); + const reactContainersRef = useRef>(new Set()); + const lastKeyPressedRef = useRef(''); + + // Get the current text value from the contentEditable + const getCurrentValue = () => editableElementRef.current?.textContent || ''; const isRefresh = useVisualRefresh(); - const isCompactMode = useDensityMode(textareaRef) === 'compact'; + const isCompactMode = useDensityMode(editableElementRef) === 'compact'; const PADDING = isRefresh ? tokens.spaceXxs : tokens.spaceXxxs; const LINE_HEIGHT = tokens.lineHeightBodyM; @@ -81,54 +87,122 @@ const InternalPromptInput = React.forwardRef( ref, () => ({ focus(...args: Parameters) { - textareaRef.current?.focus(...args); + editableElementRef.current?.focus(...args); }, select() { - textareaRef.current?.select(); + const selection = window.getSelection(); + const range = document.createRange(); + if (editableElementRef.current) { + range.selectNodeContents(editableElementRef.current); + selection?.removeAllRanges(); + selection?.addRange(range); + } }, - setSelectionRange(...args: Parameters) { - textareaRef.current?.setSelectionRange(...args); + setSelectionRange() { + // setSelectionRange is not supported on contentEditable divs + // This method is kept for API compatibility but does nothing }, }), - [textareaRef] + [editableElementRef] ); - const handleKeyDown = (event: React.KeyboardEvent) => { + const handleKeyDown: React.KeyboardEventHandler = event => { + lastKeyPressedRef.current = event.key; fireKeyboardEvent(onKeyDown, event); if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { - if (event.currentTarget.form && !event.isDefaultPrevented()) { - event.currentTarget.form.requestSubmit(); + const form = (event.currentTarget as HTMLElement).closest('form'); + if (form && !event.isDefaultPrevented()) { + form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value }); + fireNonCancelableEvent(onAction, { value: getCurrentValue() }); } }; - const handleChange = (event: React.ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: event.target.value }); + const handleChange: React.FormEventHandler = () => { + const textValue = getCurrentValue(); + + // Check if space was just pressed and there's an @mention in raw text nodes + if (lastKeyPressedRef.current === ' ') { + const mentionPattern = /(^|\s)@(\w+)\s/; + + // Find text nodes that contain @mentions + const findAndConvertMention = (node: Node): boolean => { + if (node.nodeType === Node.TEXT_NODE && node.textContent) { + const match = node.textContent.match(mentionPattern); + if (match) { + const mentionText = match[2]; + const beforeMention = node.textContent.substring(0, match.index! + match[1].length); + const afterMention = node.textContent.substring(match.index! + match[0].length); + + // Create Token container + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + reactContainersRef.current.add(container); + + // Replace text node with: before + Token + after + const parent = node.parentNode!; + if (beforeMention) { + parent.insertBefore(document.createTextNode(beforeMention), node); + } + parent.insertBefore(container, node); + if (afterMention) { + parent.insertBefore(document.createTextNode(afterMention), node); + } + parent.removeChild(node); + + // Render Token into container + ReactDOM.render(, container); + + return true; + } + } else if (node.nodeType === Node.ELEMENT_NODE && !reactContainersRef.current.has(node as HTMLElement)) { + // Search in child nodes (but skip Token containers) + for (const child of Array.from(node.childNodes)) { + if (findAndConvertMention(child)) { + return true; + } + } + } + return false; + }; + + if (editableElementRef.current) { + findAndConvertMention(editableElementRef.current); + } + } + + fireNonCancelableEvent(onChange, { value: textValue }); adjustTextareaHeight(); }; const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; const adjustTextareaHeight = useCallback(() => { - if (textareaRef.current) { + if (editableElementRef.current) { + const element = editableElementRef.current; + // Save scroll position before adjusting height + const scrollTop = element.scrollTop; + // this is required so the scrollHeight becomes dynamic, otherwise it will be locked at the highest value for the size it reached e.g. 500px - textareaRef.current.style.height = 'auto'; + element.style.height = 'auto'; - const minTextareaHeight = `calc(${LINE_HEIGHT} + ${tokens.spaceScaledXxs} * 2)`; // the min height of Textarea with 1 row + const minRowsHeight = `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; + const scrollHeight = `calc(${element.scrollHeight}px)`; if (maxRows === -1) { - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `max(${scrollHeight}, ${minTextareaHeight})`; + element.style.height = `max(${scrollHeight}, ${minRowsHeight})`; } else { const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `min(max(${scrollHeight}, ${minTextareaHeight}), ${maxRowsHeight})`; + element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; } + + // Restore scroll position after adjusting height + element.scrollTop = scrollTop; } - }, [maxRows, LINE_HEIGHT, PADDING]); + }, [maxRows, minRows, LINE_HEIGHT, PADDING]); useEffect(() => { const handleResize = () => { @@ -146,28 +220,48 @@ const InternalPromptInput = React.forwardRef( adjustTextareaHeight(); }, [value, adjustTextareaHeight, maxRows, isCompactMode]); - const attributes: React.TextareaHTMLAttributes = { + useEffect(() => { + if (autoFocus && editableElementRef.current) { + editableElementRef.current.focus(); + } + }, [autoFocus]); + + // Cleanup React containers on unmount + useEffect(() => { + const containers = reactContainersRef.current; + return () => { + containers.forEach(container => { + ReactDOM.unmountComponentAtNode(container); + }); + containers.clear(); + }; + }, []); + + // Determine if placeholder should be visible (only when value is empty) + const showPlaceholder = !getCurrentValue().trim() && placeholder; + + const attributes: React.HTMLAttributes & { + 'data-placeholder'?: string; + autoComplete?: string; + } = { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, 'aria-invalid': invalid ? 'true' : undefined, - name, - placeholder, - autoFocus, + 'aria-disabled': disabled ? 'true' : undefined, + 'aria-readonly': readOnly ? 'true' : undefined, + 'data-placeholder': placeholder, + autoComplete: convertAutoComplete(autoComplete), className: clsx(styles.textarea, testutilStyles.textarea, { [styles.invalid]: invalid, [styles.warning]: warning, + [styles['textarea-disabled']]: disabled, + [styles['placeholder-visible']]: showPlaceholder, }), - autoComplete: convertAutoComplete(autoComplete), spellCheck: spellcheck, - disabled, - readOnly: readOnly ? true : undefined, - rows: minRows, onKeyDown: handleKeyDown, onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - // We set a default value on the component in order to force it into the controlled mode. - value: value || '', - onChange: handleChange, + onInput: handleChange, onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; @@ -189,7 +283,7 @@ const InternalPromptInput = React.forwardRef( iconUrl={actionButtonIconUrl} iconSvg={actionButtonIconSvg} iconAlt={actionButtonIconAlt} - onClick={() => fireNonCancelableEvent(onAction, { value })} + onClick={() => fireNonCancelableEvent(onAction, { value: getCurrentValue() })} variant="icon" /> )} @@ -222,13 +316,14 @@ const InternalPromptInput = React.forwardRef(
)}
- } +
{hasActionButton && !secondaryActions && action}
@@ -249,7 +344,7 @@ const InternalPromptInput = React.forwardRef( > {secondaryActions}
-
textareaRef.current?.focus()} /> +
editableElementRef.current?.focus()} /> {hasActionButton && action}
)} diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index a0eefabaa1..2c50d03532 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -126,28 +126,31 @@ $invalid-border-offset: constants.$invalid-control-left-padding; .textarea { @include styles.styles-reset; - @include styles.control-border-radius-full(); @include styles.font-body-m; // Restore browsers' default resize values resize: none; // Restore default text cursor cursor: text; - // Allow multi-line placeholders + // Allow multi-line placeholders and word wrapping white-space: pre-wrap; - background-color: inherit; + word-wrap: break-word; + overflow-wrap: break-word; + background-color: transparent; padding-block: styles.$control-padding-vertical; padding-inline: styles.$control-padding-horizontal; color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-body-default); - max-inline-size: 100%; inline-size: 100%; display: block; box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; border: 0; - &::placeholder { + &.placeholder-visible::before { + content: attr(data-placeholder); @include styles.form-placeholder( $color: var(#{custom-props.$promptInputStylePlaceholderColor}, awsui.$color-text-input-placeholder), $font-size: var(#{custom-props.$promptInputStylePlaceholderFontSize}), @@ -155,6 +158,10 @@ $invalid-border-offset: constants.$invalid-control-left-padding; $font-weight: var(#{custom-props.$promptInputStylePlaceholderFontWeight}) ); opacity: 1; + pointer-events: none; + position: absolute; + inset-block-start: styles.$control-padding-vertical; + inset-inline-start: styles.$control-padding-horizontal; } &:hover { @@ -182,8 +189,11 @@ $invalid-border-offset: constants.$invalid-control-left-padding; padding-inline-start: $invalid-border-offset; } - &:disabled { + &:disabled, + &.textarea-disabled { color: var(#{custom-props.$promptInputStyleColorDisabled}, awsui.$color-text-input-disabled); + @include styles.form-disabled-element; + border: 0; cursor: default; &::placeholder { @@ -198,9 +208,15 @@ $invalid-border-offset: constants.$invalid-control-left-padding; var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-body-default) ); } + // Placeholder for disabled contentEditable div + &.placeholder-visible::before { + @include styles.form-placeholder-disabled; + opacity: 1; + } &-wrapper { display: flex; + position: relative; } } diff --git a/src/prompt-input/utils.tsx b/src/prompt-input/utils.tsx new file mode 100644 index 0000000000..2b6e52a28d --- /dev/null +++ b/src/prompt-input/utils.tsx @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Token from '../token/internal'; + +/** + * Parses text and converts @mentions to Token components. + */ +export const parseTextWithMentions = (text: string): React.ReactNode => { + const mentionRegex = /(^|\s)@(\w+)(\s?)/g; + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(mentionRegex)) { + const atSymbolIndex = match.index! + match[1].length; + + // Add text before mention + if (atSymbolIndex > lastIndex) { + parts.push(text.substring(lastIndex, atSymbolIndex)); + } + + // Add Token + parts.push(); + + // Add trailing space + if (match[3]) { + parts.push(match[3]); + } + + lastIndex = match.index! + match[0].length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + + return parts.length > 0 ? <>{parts} : text; +}; + +/** + * Renders React content into a contentEditable element. + */ +export const renderReactContentToDOM = ( + content: React.ReactNode, + targetElement: HTMLElement, + reactContainers: Set +): void => { + // Cleanup + reactContainers.forEach(container => ReactDOM.unmountComponentAtNode(container)); + reactContainers.clear(); + targetElement.innerHTML = ''; + + const renderNode = (node: React.ReactNode, parent: HTMLElement) => { + if (typeof node === 'string' || typeof node === 'number') { + parent.appendChild(document.createTextNode(String(node))); + } else if (React.isValidElement(node)) { + if (node.type === React.Fragment) { + renderNode((node.props as any)?.children, parent); + return; + } + + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + parent.appendChild(container); + reactContainers.add(container); + + ReactDOM.render(node, container); + } else if (Array.isArray(node)) { + node.forEach(child => renderNode(child, parent)); + } + }; + + renderNode(content, targetElement); + + // Ensure cursor can be placed at end + if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { + targetElement.appendChild(document.createTextNode('')); + } +}; From ba6f55ae6dd878c97029ef734426c513c8615e7d Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Tue, 18 Nov 2025 12:47:45 +0100 Subject: [PATCH 2/6] Clean up complex logic related to mention handling --- .../prompt-input/prompt-input-integ.page.tsx | 2 +- pages/prompt-input/simple.page.tsx | 238 ++++++++-------- src/prompt-input/index.tsx | 3 - src/prompt-input/interfaces.ts | 57 ++-- src/prompt-input/internal.tsx | 262 +++++++++++++----- src/prompt-input/utils.tsx | 103 +++---- src/token/internal.tsx | 3 + 7 files changed, 371 insertions(+), 297 deletions(-) diff --git a/pages/prompt-input/prompt-input-integ.page.tsx b/pages/prompt-input/prompt-input-integ.page.tsx index 56b85be55d..1913a12323 100644 --- a/pages/prompt-input/prompt-input-integ.page.tsx +++ b/pages/prompt-input/prompt-input-integ.page.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import PromptInput from '~components/prompt-input'; export default function Page() { - const [value, setValue] = useState(''); + const [value, setValue] = useState(''); const [submitStatus, setSubmitStatus] = useState(false); const [isKeyboardSubmittingDisabled, setDisableKeyboardSubmitting] = useState(false); diff --git a/pages/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx index 01fbf42093..83f7e040b8 100644 --- a/pages/prompt-input/simple.page.tsx +++ b/pages/prompt-input/simple.page.tsx @@ -15,6 +15,7 @@ import { SpaceBetween, SplitPanel, } from '~components'; +import getPromptText from '~components/prompt-input/utils'; import AppContext, { AppContextType } from '../app/app-context'; import labels from '../app-layout/utils/labels'; @@ -37,6 +38,7 @@ type DemoContext = React.Context< disableBrowserAutocorrect: boolean; enableSpellcheck: boolean; hasName: boolean; + enableAutoFocus: boolean; }> >; @@ -44,9 +46,11 @@ const placeholderText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; export default function PromptInputPage() { - const [textareaValue, setTextareaValue] = useState(''); - const [valueInSplitPanel, setValueInSplitPanel] = useState(''); + const [textareaValue, setTextareaValue] = useState(''); + const [valueInSplitPanel, setValueInSplitPanel] = useState(''); const [files, setFiles] = useState([]); + const [extractedText, setExtractedText] = useState(''); + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); const { @@ -63,6 +67,7 @@ export default function PromptInputPage() { disableBrowserAutocorrect, enableSpellcheck, hasName, + enableAutoFocus, } = urlParams; const [items, setItems] = React.useState([ @@ -78,7 +83,7 @@ export default function PromptInputPage() { }, [hasText]); useEffect(() => { - if (textareaValue !== placeholderText) { + if (typeof textareaValue === 'string' && textareaValue !== placeholderText) { setUrlParams({ hasText: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -211,6 +216,16 @@ export default function PromptInputPage() { > Has name attribute (for forms) + + setUrlParams({ + enableAutoFocus: !enableAutoFocus, + }) + } + > + Enable auto focus + + {extractedText && ( + +
+ Last extracted text (using extractTextFromReactNode): + + {extractedText} + +
+
+ )} +
{ event.preventDefault(); const formData = new FormData(event.currentTarget); - const data: Record = {}; - const entries: Array<{ name: string; value: string }> = []; - - formData.forEach((value, key) => { - data[key] = String(value); - entries.push({ name: key, value: String(value) }); - }); - - console.log('FORM SUBMITTED:', { - formData: data, - entries, + console.log('FORM SUBMITTED (fallback):', { + 'user-prompt': formData.get('user-prompt'), }); }} > MAX_CHARS || isInvalid) && 'The query has too many characters.'} + errorText={ + (getPromptText(textareaValue, true).length > MAX_CHARS || isInvalid) && + 'The query has too many characters.' + } warningText={hasWarning && 'This input has a warning'} constraintText={ <> - This service is subject to some policy. Character count: {textareaValue.length}/{MAX_CHARS} + This service is subject to some policy. Character count:{' '} + {getPromptText(textareaValue, true).length}/{MAX_CHARS} } label={User prompt} i18nStrings={{ errorIconAriaLabel: 'Error' }} > -
{ - // Prevent form submission from secondary action buttons - e.preventDefault(); - e.stopPropagation(); + setTextareaValue(event.detail.value)} + onAction={event => { + // Demo: Extract plain text from ReactNode value using utility + // In a real app, you'd send this extracted text to your backend + const plainText = getPromptText(event.detail.value); + + setExtractedText(plainText); + window.alert(`Submitted plain text: ${plainText}`); }} - > - setTextareaValue(event.detail.value)} - onAction={event => window.alert(`Submitted the following: ${event.detail.value}`)} - placeholder="Ask a question" - maxRows={hasInfiniteMaxRows ? -1 : 4} - disabled={isDisabled} - readOnly={isReadOnly} - invalid={isInvalid || textareaValue.length > MAX_CHARS} - warning={hasWarning} - ref={ref} - disableSecondaryActionsPaddings={true} - disableActionButton={disableActionButton} - disableBrowserAutocorrect={disableBrowserAutocorrect} - spellcheck={enableSpellcheck} - name={hasName ? 'user-prompt' : undefined} - customPrimaryAction={ - hasPrimaryActions ? ( + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || getPromptText(textareaValue, true).length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + disableActionButton={disableActionButton} + disableBrowserAutocorrect={disableBrowserAutocorrect} + spellcheck={enableSpellcheck} + name={hasName ? 'user-prompt' : undefined} + autoFocus={enableAutoFocus} + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + detail.id.includes('files') && setFiles(detail.files)} items={[ + { + type: 'icon-file-input', + id: 'files', + text: 'Upload files', + multiple: true, + }, { type: 'icon-button', - id: 'record', - text: 'Record', - iconName: 'microphone', + id: 'expand', + iconName: 'expand', + text: 'Go full page', disabled: isDisabled || isReadOnly, }, { type: 'icon-button', - id: 'submit', - text: 'Submit', - iconName: 'send', + id: 'remove', + iconName: 'remove', + text: 'Remove', disabled: isDisabled || isReadOnly, }, ]} + variant="icon" /> - ) : undefined - } - secondaryActions={ - hasSecondaryActions ? ( - - detail.id.includes('files') && setFiles(detail.files)} - onItemClick={({ detail }) => { - if (detail.id === 'bug') { - // Add @debug mention - will be automatically converted to Token - setTextareaValue(prev => `${prev} @debug `); - } - }} - items={[ - { - type: 'icon-file-input', - id: 'files', - text: 'Upload files', - multiple: true, - }, - { - type: 'icon-button', - id: 'bug', - iconName: 'bug', - text: 'Add debug token', - disabled: isDisabled || isReadOnly, - }, - { - type: 'icon-button', - id: 'expand', - iconName: 'expand', - text: 'Go full page', - disabled: isDisabled || isReadOnly, - }, - { - type: 'icon-button', - id: 'remove', - iconName: 'remove', - text: 'Remove', - disabled: isDisabled || isReadOnly, - }, - ]} - variant="icon" - /> - - ) : undefined - } - secondaryContent={ - hasSecondaryContent && files.length > 0 ? ( - ({ - file, - }))} - showFileThumbnail={true} - onDismiss={onDismiss} - i18nStrings={i18nStrings} - alignment="horizontal" - /> - ) : undefined - } - /> -
+ + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + />
diff --git a/src/prompt-input/index.tsx b/src/prompt-input/index.tsx index af9857f233..fb8c5a8884 100644 --- a/src/prompt-input/index.tsx +++ b/src/prompt-input/index.tsx @@ -13,7 +13,6 @@ export { PromptInputProps }; const PromptInput = React.forwardRef( ( { - autoComplete, autoFocus, disableBrowserAutocorrect, disableActionButton, @@ -29,7 +28,6 @@ const PromptInput = React.forwardRef( const baseComponentProps = useBaseComponent('PromptInput', { props: { readOnly, - autoComplete, autoFocus, disableBrowserAutocorrect, disableActionButton, @@ -42,7 +40,6 @@ const PromptInput = React.forwardRef( return ( , + extends Omit, InputKeyEvents, InputAutoCorrect, - InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { /** * Specifies the content of the prompt input. + * Can be a string or React nodes including Token components. */ - value?: string; + value?: React.ReactNode; + + /** + * Called whenever a user changes the input value (by typing or pasting). + * The event `detail` contains the current value as a React.ReactNode. + */ + onChange?: NonCancelableEventHandler; /** * Called whenever a user clicks the action button or presses the "Enter" key. @@ -123,18 +120,6 @@ export interface PromptInputProps */ disableSecondaryContentPaddings?: boolean; - /** - * Attributes to add to the native `textarea` element. - * Some attributes will be automatically combined with internal attribute values: - * - `className` will be appended. - * - Event handlers will be chained, unless the default is prevented. - * - * We do not support using this attribute to apply custom styling. - * - * @awsuiSystem core - */ - nativeTextareaAttributes?: NativeAttributes>; - /** * @awsuiSystem core */ @@ -143,7 +128,14 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; - export type ActionDetail = BaseChangeDetail; + + export interface ChangeDetail { + value: React.ReactNode; + } + + export interface ActionDetail { + value: React.ReactNode; + } export interface Ref { /** @@ -155,15 +147,6 @@ export namespace PromptInputProps { * Selects all text in the textarea control. */ select(): void; - - /** - * Selects a range of text in the textarea control. - * - * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement/setSelectionRange - * for more details on this method. Be aware that using this method in React has some - * common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-setselectionrange-incompatible-with-react-hooks - */ - setSelectionRange(start: number | null, end: number | null, direction?: 'forward' | 'backward' | 'none'): void; } export interface Style { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index 38908eae5c..c897821ed1 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -7,7 +7,6 @@ import clsx from 'clsx'; import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; -import { convertAutoComplete } from '../input/utils'; import { getBaseProps } from '../internal/base-component'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; @@ -18,10 +17,117 @@ import { SomeRequired } from '../internal/types'; import Token from '../token/internal'; import { PromptInputProps } from './interfaces'; import { getPromptInputStyles } from './styles'; +import { getPromptText } from './utils'; import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; +/** + * Helper to render a Token element into a non-editable container. + * If beforeNode is provided, inserts before it; otherwise appends to parent. + */ +function renderTokenToDOM( + tokenElement: React.ReactElement, + parent: HTMLElement, + reactContainers: Set, + beforeNode?: Node | null +): HTMLElement { + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + + if (beforeNode) { + parent.insertBefore(container, beforeNode); + } else { + parent.appendChild(container); + } + reactContainers.add(container); + ReactDOM.render(tokenElement, container); + + return container; +} + +/** + * Renders React content (strings and Tokens) into a contentEditable element. + * Tokens are wrapped in non-editable spans with data-token-value attributes. + */ +function renderReactContentToDOM( + content: React.ReactNode, + targetElement: HTMLElement, + reactContainers: Set +): void { + // Clean up previous render + reactContainers.forEach(container => ReactDOM.unmountComponentAtNode(container)); + reactContainers.clear(); + targetElement.innerHTML = ''; + + const renderNode = (node: React.ReactNode, parent: HTMLElement) => { + // Primitives: render as text + if (typeof node === 'string' || typeof node === 'number') { + parent.appendChild(document.createTextNode(String(node))); + return; + } + + // Arrays: render each child + if (Array.isArray(node)) { + node.forEach(child => renderNode(child, parent)); + return; + } + + // React elements: Token or Fragment + const element = node as React.ReactElement; + + // Fragment: render children directly + if (element.type === React.Fragment) { + renderNode(element.props?.children, parent); + return; + } + + // Token: render in non-editable span + renderTokenToDOM(element, parent, reactContainers); + }; + + renderNode(content, targetElement); + + // Ensure cursor can be placed at end + if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { + targetElement.appendChild(document.createTextNode('')); + } +} + +/** + * Converts DOM back to ReactNode, preserving Tokens with their labels and values. + * Extracts the label from DOM text content and value from data-token-value attribute. + */ +function domToReactNode(node: Node): React.ReactNode { + // Element with data-token-value: convert to Token + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + const tokenValue = element.getAttribute('data-token-value'); + if (tokenValue) { + // Extract label from the DOM text content (rendered by Token component) + const label = element.textContent || ''; + return ; + } + } + + // Text node: return text content + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ''; + } + + // Other elements: recursively convert children + const children: React.ReactNode[] = []; + for (const child of Array.from(node.childNodes)) { + const childNode = domToReactNode(child); + if (childNode) { + children.push(childNode); + } + } + + return children.length === 0 ? '' : children.length === 1 ? children[0] : <>{children}; +} + interface InternalPromptInputProps extends SomeRequired, InternalBaseComponentProps {} @@ -36,7 +142,6 @@ const InternalPromptInput = React.forwardRef( actionButtonIconSvg, actionButtonIconAlt, ariaLabel, - autoComplete, autoFocus, disableActionButton, disableBrowserAutocorrect, @@ -68,13 +173,10 @@ const InternalPromptInput = React.forwardRef( const baseProps = getBaseProps(rest); - // Get the current text value (will be updated on each render) const editableElementRef = useRef(null); const reactContainersRef = useRef>(new Set()); - const lastKeyPressedRef = useRef(''); - - // Get the current text value from the contentEditable - const getCurrentValue = () => editableElementRef.current?.textContent || ''; + const isRenderingRef = useRef(false); + const lastValueRef = useRef(value); const isRefresh = useVisualRefresh(); const isCompactMode = useDensityMode(editableElementRef) === 'compact'; @@ -98,16 +200,11 @@ const InternalPromptInput = React.forwardRef( selection?.addRange(range); } }, - setSelectionRange() { - // setSelectionRange is not supported on contentEditable divs - // This method is kept for API compatibility but does nothing - }, }), [editableElementRef] ); const handleKeyDown: React.KeyboardEventHandler = event => { - lastKeyPressedRef.current = event.key; fireKeyboardEvent(onKeyDown, event); if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { @@ -116,65 +213,76 @@ const InternalPromptInput = React.forwardRef( form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value: getCurrentValue() }); + fireNonCancelableEvent(onAction, { value }); } - }; - const handleChange: React.FormEventHandler = () => { - const textValue = getCurrentValue(); - - // Check if space was just pressed and there's an @mention in raw text nodes - if (lastKeyPressedRef.current === ' ') { - const mentionPattern = /(^|\s)@(\w+)\s/; - - // Find text nodes that contain @mentions - const findAndConvertMention = (node: Node): boolean => { - if (node.nodeType === Node.TEXT_NODE && node.textContent) { - const match = node.textContent.match(mentionPattern); - if (match) { - const mentionText = match[2]; - const beforeMention = node.textContent.substring(0, match.index! + match[1].length); - const afterMention = node.textContent.substring(match.index! + match[0].length); - - // Create Token container - const container = document.createElement('span'); - container.style.display = 'inline'; - container.contentEditable = 'false'; - reactContainersRef.current.add(container); - - // Replace text node with: before + Token + after - const parent = node.parentNode!; - if (beforeMention) { - parent.insertBefore(document.createTextNode(beforeMention), node); - } - parent.insertBefore(container, node); - if (afterMention) { - parent.insertBefore(document.createTextNode(afterMention), node); - } - parent.removeChild(node); - - // Render Token into container - ReactDOM.render(, container); - - return true; - } - } else if (node.nodeType === Node.ELEMENT_NODE && !reactContainersRef.current.has(node as HTMLElement)) { - // Search in child nodes (but skip Token containers) - for (const child of Array.from(node.childNodes)) { - if (findAndConvertMention(child)) { - return true; - } + // Detect space key for @mention conversion (prototype feature) + if (event.key === ' ' && editableElementRef.current) { + // Check if there's an @mention pattern before the cursor + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const textNode = range.startContainer; + + if (textNode.nodeType === Node.TEXT_NODE && textNode.textContent) { + const textBeforeCursor = textNode.textContent.substring(0, range.startOffset); + const mentionMatch = textBeforeCursor.match(/(^|\s)@(\w+)$/); + + if (mentionMatch) { + // Prevent default space insertion + event.preventDefault(); + + const mentionText = mentionMatch[2]; + const beforeMention = textBeforeCursor.substring(0, mentionMatch.index! + mentionMatch[1].length); + const afterCursor = textNode.textContent.substring(range.startOffset); + + // Create the full token value (will come from menu selection in the future) + const tokenValue = `${mentionText}`; + + // Replace text with: before + Token + space + after + const parent = textNode.parentNode as HTMLElement; + const beforeText = document.createTextNode(beforeMention); + const spaceText = document.createTextNode(' '); + const afterText = document.createTextNode(afterCursor); + + parent.insertBefore(beforeText, textNode); + + // Render Token using helper (insert before textNode) + const tokenElement = ; + renderTokenToDOM(tokenElement, parent, reactContainersRef.current, textNode); + + parent.insertBefore(spaceText, textNode); + parent.insertBefore(afterText, textNode); + parent.removeChild(textNode); + + // Place cursor after the space + const newRange = document.createRange(); + newRange.setStart(spaceText, 1); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + + // Trigger change event + const changeEvent = new Event('input', { bubbles: true }); + editableElementRef.current.dispatchEvent(changeEvent); } } - return false; - }; - - if (editableElementRef.current) { - findAndConvertMention(editableElementRef.current); } } + }; + + const handleChange: React.FormEventHandler = () => { + if (isRenderingRef.current) { + return; // Skip onChange during programmatic rendering + } - fireNonCancelableEvent(onChange, { value: textValue }); + // Convert DOM back to ReactNode and emit to consumer + if (!editableElementRef.current) { + return; + } + const reactNodeValue = domToReactNode(editableElementRef.current); + lastValueRef.current = reactNodeValue; // Track the value we're emitting + fireNonCancelableEvent(onChange, { value: reactNodeValue }); adjustTextareaHeight(); }; @@ -216,7 +324,21 @@ const InternalPromptInput = React.forwardRef( }; }, [adjustTextareaHeight]); + // Render value prop into contentEditable only when it changes externally useEffect(() => { + if (!editableElementRef.current) { + return; + } + + // Only re-render if the value actually changed from outside + // (not from user typing which would have updated lastValueRef) + if (value !== lastValueRef.current) { + isRenderingRef.current = true; + renderReactContentToDOM(value || '', editableElementRef.current, reactContainersRef.current); + isRenderingRef.current = false; + lastValueRef.current = value; + } + adjustTextareaHeight(); }, [value, adjustTextareaHeight, maxRows, isCompactMode]); @@ -224,7 +346,8 @@ const InternalPromptInput = React.forwardRef( if (autoFocus && editableElementRef.current) { editableElementRef.current.focus(); } - }, [autoFocus]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Cleanup React containers on unmount useEffect(() => { @@ -238,11 +361,10 @@ const InternalPromptInput = React.forwardRef( }, []); // Determine if placeholder should be visible (only when value is empty) - const showPlaceholder = !getCurrentValue().trim() && placeholder; + const showPlaceholder = !value && placeholder; const attributes: React.HTMLAttributes & { 'data-placeholder'?: string; - autoComplete?: string; } = { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, @@ -250,8 +372,8 @@ const InternalPromptInput = React.forwardRef( 'aria-invalid': invalid ? 'true' : undefined, 'aria-disabled': disabled ? 'true' : undefined, 'aria-readonly': readOnly ? 'true' : undefined, + 'aria-required': rest.ariaRequired ? 'true' : undefined, 'data-placeholder': placeholder, - autoComplete: convertAutoComplete(autoComplete), className: clsx(styles.textarea, testutilStyles.textarea, { [styles.invalid]: invalid, [styles.warning]: warning, @@ -283,7 +405,7 @@ const InternalPromptInput = React.forwardRef( iconUrl={actionButtonIconUrl} iconSvg={actionButtonIconSvg} iconAlt={actionButtonIconAlt} - onClick={() => fireNonCancelableEvent(onAction, { value: getCurrentValue() })} + onClick={() => fireNonCancelableEvent(onAction, { value })} variant="icon" /> )} @@ -316,7 +438,7 @@ const InternalPromptInput = React.forwardRef(
)}
- {name && } + {name && }
Hello ; + * getPromptText(value); // "Hello user" + * getPromptText(value, true); // "Hello user" */ -export const parseTextWithMentions = (text: string): React.ReactNode => { - const mentionRegex = /(^|\s)@(\w+)(\s?)/g; - const parts: React.ReactNode[] = []; - let lastIndex = 0; - - for (const match of text.matchAll(mentionRegex)) { - const atSymbolIndex = match.index! + match[1].length; - - // Add text before mention - if (atSymbolIndex > lastIndex) { - parts.push(text.substring(lastIndex, atSymbolIndex)); - } - - // Add Token - parts.push(); - - // Add trailing space - if (match[3]) { - parts.push(match[3]); - } - - lastIndex = match.index! + match[0].length; +export function getPromptText(value: React.ReactNode, labelsOnly = false): string { + if (!value) { + return ''; } - - // Add remaining text - if (lastIndex < text.length) { - parts.push(text.substring(lastIndex)); + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number') { + return String(value); + } + if (Array.isArray(value)) { + return value.map(child => getPromptText(child, labelsOnly)).join(''); } - return parts.length > 0 ? <>{parts} : text; -}; - -/** - * Renders React content into a contentEditable element. - */ -export const renderReactContentToDOM = ( - content: React.ReactNode, - targetElement: HTMLElement, - reactContainers: Set -): void => { - // Cleanup - reactContainers.forEach(container => ReactDOM.unmountComponentAtNode(container)); - reactContainers.clear(); - targetElement.innerHTML = ''; - - const renderNode = (node: React.ReactNode, parent: HTMLElement) => { - if (typeof node === 'string' || typeof node === 'number') { - parent.appendChild(document.createTextNode(String(node))); - } else if (React.isValidElement(node)) { - if (node.type === React.Fragment) { - renderNode((node.props as any)?.children, parent); - return; - } - - const container = document.createElement('span'); - container.style.display = 'inline'; - container.contentEditable = 'false'; - parent.appendChild(container); - reactContainers.add(container); - - ReactDOM.render(node, container); - } else if (Array.isArray(node)) { - node.forEach(child => renderNode(child, parent)); - } - }; - - renderNode(content, targetElement); - - // Ensure cursor can be placed at end - if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { - targetElement.appendChild(document.createTextNode('')); + // React element (Token or Fragment) + const element = value as React.ReactElement; + if (element.type === Token) { + // Return label or full value based on labelsOnly flag + return labelsOnly ? element.props.label || '' : element.props.value || element.props.label || ''; } -}; + return getPromptText(element.props.children, labelsOnly); +} + +export default getPromptText; diff --git a/src/token/internal.tsx b/src/token/internal.tsx index a94bd4cb68..6d906d0795 100644 --- a/src/token/internal.tsx +++ b/src/token/internal.tsx @@ -23,6 +23,7 @@ type InternalTokenProps = TokenProps & InternalBaseComponentProps & { role?: string; disableInnerPadding?: boolean; + value?: string; }; function InternalToken({ @@ -43,6 +44,7 @@ function InternalToken({ // Internal role, disableInnerPadding, + value, // Base __internalRootRef, @@ -126,6 +128,7 @@ function InternalToken({ setShowTooltip(false); }} tabIndex={!!tooltipContent && isInline && isEllipsisActive ? 0 : undefined} + data-token-value={value} >
Date: Wed, 3 Dec 2025 17:39:55 +0100 Subject: [PATCH 3/6] Simplify and extend shortcuts interface capabilities --- package-lock.json | 71 ++- .../prompt-input/prompt-input-integ.page.tsx | 4 +- pages/prompt-input/simple.page.tsx | 167 +++++- src/prompt-input/interfaces.ts | 156 +++++- src/prompt-input/internal.tsx | 508 ++++++++++++------ src/prompt-input/utils.tsx | 47 +- 6 files changed, 705 insertions(+), 248 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19b7fbdb38..8fd67e77ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -769,6 +769,7 @@ "version": "7.27.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1222,6 +1223,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1243,6 +1245,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1290,6 +1293,7 @@ "node_modules/@dnd-kit/core": { "version": "6.3.1", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2399,7 +2403,6 @@ "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -2463,7 +2466,6 @@ "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -2488,7 +2490,6 @@ "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" @@ -2503,7 +2504,6 @@ "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -4272,6 +4272,7 @@ "version": "9.6.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4471,6 +4472,7 @@ "version": "16.14.34", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4639,6 +4641,7 @@ "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.44.0", @@ -4669,6 +4672,7 @@ "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -4913,7 +4917,6 @@ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -4927,7 +4930,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -4942,8 +4944,7 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@wdio/config": { "version": "9.16.2", @@ -5008,6 +5009,7 @@ "version": "9.16.2", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.20.0" }, @@ -5028,6 +5030,7 @@ "version": "9.16.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.1.2", "loglevel": "^1.6.0", @@ -5335,6 +5338,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5385,6 +5389,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5949,6 +5954,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -6302,6 +6308,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", @@ -7377,6 +7384,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7877,7 +7885,6 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -8868,6 +8875,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8929,6 +8937,7 @@ "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9505,7 +9514,6 @@ "integrity": "sha512-7bc5I2dU3onKJaRhBdxKh/C+W+ot7R+RcRMCLTSR7cbfHM9Shk8ocbNDVvjrxaBdA52kbZONVSyhexp7cq2xNA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/snapshot": "^3.2.4", "deep-eql": "^5.0.2", @@ -9538,7 +9546,6 @@ "integrity": "sha512-5YUHr27fpJ64dnvtu+tt11ewATynrHkGYD+uSFgRr8V2eFJis/vEXgToyLwccIwqBihVfz9jwio+Zr1ab1Zihw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0" }, @@ -9552,7 +9559,6 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -9566,7 +9572,6 @@ "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -9585,8 +9590,7 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-webdriverio/node_modules/chalk": { "version": "4.1.2", @@ -9594,7 +9598,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9618,7 +9621,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -9629,7 +9631,6 @@ "integrity": "sha512-OKe7cdic4qbfWd/CcgwJvvCrNX2KWfuMZee9AfJHL1gTYmvqjBjZG1a2NwfhspBzxzlXwsN75WWpKTYfsJpBxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/expect-utils": "30.1.1", "@jest/get-type": "30.1.0", @@ -9648,7 +9649,6 @@ "integrity": "sha512-LUU2Gx8EhYxpdzTR6BmjL1ifgOAQJQELTHOiPv9KITaKjZvJ9Jmgigx01tuZ49id37LorpGc9dPBPlXTboXScw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", @@ -9665,7 +9665,6 @@ "integrity": "sha512-SuH2QVemK48BNTqReti6FtjsMPFsSOD/ZzRxU1TttR7RiRsRSe78d03bb4Cx6D4bQC/80Q8U4VnaaAH9FlbZ9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", @@ -9682,7 +9681,6 @@ "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.0.5", @@ -9704,7 +9702,6 @@ "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", @@ -9720,7 +9717,6 @@ "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", @@ -9739,7 +9735,6 @@ "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -9755,7 +9750,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9768,8 +9762,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/express": { "version": "4.21.2", @@ -12501,6 +12494,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -12865,6 +12859,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -13491,6 +13486,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -14836,6 +14832,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16132,6 +16129,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -16746,6 +16744,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17129,6 +17128,7 @@ "node_modules/react": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -17141,6 +17141,7 @@ "node_modules/react-dom": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -17814,6 +17815,7 @@ "version": "3.29.5", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -18479,6 +18481,7 @@ "version": "11.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^4.0.3", @@ -19198,6 +19201,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -19456,6 +19460,7 @@ "version": "7.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19838,6 +19843,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20004,7 +20010,6 @@ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -20231,7 +20236,8 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -20379,6 +20385,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20853,6 +20860,7 @@ "version": "9.16.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", @@ -20904,6 +20912,7 @@ "version": "5.99.9", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -20950,6 +20959,7 @@ "version": "5.1.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -21030,6 +21040,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21140,6 +21151,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21226,6 +21238,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/pages/prompt-input/prompt-input-integ.page.tsx b/pages/prompt-input/prompt-input-integ.page.tsx index 1913a12323..dd9f60b34e 100644 --- a/pages/prompt-input/prompt-input-integ.page.tsx +++ b/pages/prompt-input/prompt-input-integ.page.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import PromptInput from '~components/prompt-input'; export default function Page() { - const [value, setValue] = useState(''); + const [value, setValue] = useState(''); const [submitStatus, setSubmitStatus] = useState(false); const [isKeyboardSubmittingDisabled, setDisableKeyboardSubmitting] = useState(false); @@ -25,7 +25,7 @@ export default function Page() { actionButtonIconName="send" actionButtonAriaLabel="Send" value={value} - onChange={event => setValue(event.detail.value)} + onChange={event => setValue(event.detail.value ?? '')} onAction={() => window.alert('Sent message!')} onKeyDown={event => { if (isKeyboardSubmittingDisabled) { diff --git a/pages/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx index 83f7e040b8..e539466abe 100644 --- a/pages/prompt-input/simple.page.tsx +++ b/pages/prompt-input/simple.page.tsx @@ -12,6 +12,7 @@ import { FileTokenGroup, FormField, PromptInput, + PromptInputProps, SpaceBetween, SplitPanel, } from '~components'; @@ -39,6 +40,7 @@ type DemoContext = React.Context< enableSpellcheck: boolean; hasName: boolean; enableAutoFocus: boolean; + enableReferences: boolean; }> >; @@ -46,10 +48,14 @@ const placeholderText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; export default function PromptInputPage() { - const [textareaValue, setTextareaValue] = useState(''); - const [valueInSplitPanel, setValueInSplitPanel] = useState(''); + const [textareaValue, setTextareaValue] = useState(''); + const [tokens, setTokens] = useState([]); + const [valueInSplitPanel, setValueInSplitPanel] = useState(''); const [files, setFiles] = useState([]); const [extractedText, setExtractedText] = useState(''); + const [lastCommandToken, setLastCommandToken] = useState(null); + const [selectionStart, setSelectionStart] = useState('0'); + const [selectionEnd, setSelectionEnd] = useState('0'); const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); @@ -68,6 +74,7 @@ export default function PromptInputPage() { enableSpellcheck, hasName, enableAutoFocus, + enableReferences, } = urlParams; const [items, setItems] = React.useState([ @@ -78,16 +85,27 @@ export default function PromptInputPage() { useEffect(() => { if (hasText) { - setTextareaValue(placeholderText); + if (enableReferences) { + setTokens([{ type: 'text', text: placeholderText }]); + } else { + setTextareaValue(placeholderText); + } } - }, [hasText]); + }, [hasText, enableReferences]); useEffect(() => { - if (typeof textareaValue === 'string' && textareaValue !== placeholderText) { - setUrlParams({ hasText: false }); + if (enableReferences) { + const plainText = getPromptText(tokens); + if (plainText !== placeholderText) { + setUrlParams({ hasText: false }); + } + } else { + if (textareaValue !== placeholderText) { + setUrlParams({ hasText: false }); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [textareaValue]); + }, [textareaValue, tokens, enableReferences]); useEffect(() => { if (items.length === 0) { @@ -106,7 +124,7 @@ export default function PromptInputPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDisabled]); - const ref = React.createRef(); + const ref = React.createRef(); const buttonGroupRef = React.useRef(null); @@ -226,6 +244,17 @@ export default function PromptInputPage() { > Enable auto focus + { + setUrlParams({ enableReferences: !enableReferences }); + // Reset values when switching modes + setTextareaValue(''); + setTokens([]); + }} + > + Enable references (tokens mode - supports @mentions and /commands) + +
+ + + +
+ {extractedText && (
- Last extracted text (using extractTextFromReactNode): + Last extracted text (using getPromptText): {extractedText} @@ -249,6 +314,17 @@ export default function PromptInputPage() { )} + {enableReferences && tokens.length > 0 && ( + +
+ Current tokens: + + {JSON.stringify(tokens, null, 2)} + +
+
+ )} + { event.preventDefault(); @@ -261,17 +337,19 @@ export default function PromptInputPage() { MAX_CHARS || isInvalid) && + ((enableReferences ? getPromptText(tokens, true).length : textareaValue.length) > MAX_CHARS || + isInvalid) && 'The query has too many characters.' } warningText={hasWarning && 'This input has a warning'} constraintText={ <> This service is subject to some policy. Character count:{' '} - {getPromptText(textareaValue, true).length}/{MAX_CHARS} + {enableReferences ? getPromptText(tokens, true).length : textareaValue.length}/{MAX_CHARS} + {enableReferences && ' (Token mode: type @word or /command and press space)'} } - label={User prompt} + label={User prompt {enableReferences && '(with references)'}} i18nStrings={{ errorIconAriaLabel: 'Error' }} > setTextareaValue(event.detail.value)} - onAction={event => { - // Demo: Extract plain text from ReactNode value using utility - // In a real app, you'd send this extracted text to your backend - const plainText = getPromptText(event.detail.value); + value={enableReferences ? undefined : textareaValue} + tokens={enableReferences ? tokens : undefined} + onChange={(event: any) => { + if (enableReferences) { + const newTokens = event.detail.tokens; + setTokens(newTokens); - setExtractedText(plainText); - window.alert(`Submitted plain text: ${plainText}`); + // Check if user manually removed the command token + const hasCommandToken = newTokens.some( + (token: PromptInputProps.InputToken) => + token.type === 'reference' && token.id.startsWith('command:') + ); + if (!hasCommandToken && lastCommandToken) { + // User removed the command, clear it so it doesn't come back + setLastCommandToken(null); + } + } else { + setTextareaValue(event.detail.value); + } + }} + onAction={({ detail }) => { + const plainText = enableReferences ? getPromptText(detail.tokens ?? []) : detail.value; + + setExtractedText(plainText ?? ''); + + if (enableReferences) { + // Find command token in submitted tokens + const commandToken = detail.tokens?.find( + (token: PromptInputProps.InputToken) => + token.type === 'reference' && token.id.startsWith('command:') + ) as PromptInputProps.ReferenceInputToken | undefined; + + if (commandToken) { + // Save command token and restore it with a space after clearing + setLastCommandToken(commandToken); + setTokens([commandToken, { type: 'text', text: ' ' }]); + } else { + // No command token, clear everything + setTokens([]); + } + } else { + setTextareaValue(''); + } + + window.alert( + `Submitted:\n\nPlain text: ${plainText}\n\n${ + enableReferences ? `Tokens: ${JSON.stringify(detail.tokens, null, 2)}` : '' + }` + ); }} placeholder="Ask a question" maxRows={hasInfiniteMaxRows ? -1 : 4} disabled={isDisabled} readOnly={isReadOnly} - invalid={isInvalid || getPromptText(textareaValue, true).length > MAX_CHARS} + invalid={ + isInvalid || + (enableReferences + ? getPromptText(tokens, true).length > MAX_CHARS + : textareaValue.length > MAX_CHARS) + } warning={hasWarning} ref={ref} disableSecondaryActionsPaddings={true} @@ -400,7 +523,7 @@ export default function PromptInputPage() { setValueInSplitPanel(event.detail.value)} + onChange={event => setValueInSplitPanel(event.detail.value ?? '')} /> } diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 6fb9fe66fc..2e730ffe7a 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -3,23 +3,40 @@ import React from 'react'; import { IconProps } from '../icon/interfaces'; -import { BaseInputProps, InputAutoCorrect, InputKeyEvents, InputSpellcheck } from '../input/interfaces'; +import { + BaseInputProps, + InputAutoComplete, + InputAutoCorrect, + InputKeyEvents, + InputSpellcheck, +} from '../input/interfaces'; import { BaseComponentProps } from '../internal/base-component'; +import { OptionsFilteringType, OptionsLoadItemsDetail } from '../internal/components/dropdown/interfaces'; +import { DropdownStatusProps } from '../internal/components/dropdown-status'; +import { OptionDefinition } from '../internal/components/option/interfaces'; import { FormFieldValidationControlProps } from '../internal/context/form-field-context'; import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; +import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps extends Omit, InputKeyEvents, InputAutoCorrect, + InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { /** - * Specifies the content of the prompt input. - * Can be a string or React nodes including Token components. + * Specifies the content of the prompt input, not in use if `tokens` is defined. */ - value?: React.ReactNode; + value?: string; + + /** + * Specifies the content of the prompt input when modes/references are in use. + * Represented as an array of TextToken or ReferenceToken. + * When defined, `autocomplete` will no longer function. + */ + tokens?: readonly PromptInputProps.InputToken[]; /** * Called whenever a user changes the input value (by typing or pasting). @@ -120,6 +137,31 @@ export interface PromptInputProps */ disableSecondaryContentPaddings?: boolean; + /** + * Menus that can be triggered via shortcuts (e.g., "/" or "@"). + * For menus only relevant to triggers at the start of the input, set `useAtStart: true`, defaults to `false`. + */ + menus?: PromptInputProps.MenuDefinition[]; + + /** + * The ID of the menu to show. When undefined, no menu is shown. + * If undefined then the input can show a menu based depending on the shortcut trigger. + */ + activeMenuId?: string; + + /** + * Attributes to add to the native `textarea` element. + * Some attributes will be automatically combined with internal attribute values: + * - `className` will be appended. + * - Event handlers will be chained, unless the default is prevented. + * + * We do not support using this attribute to apply custom styling. + * If `tokens` is defined, nativeTextareaAttributes will be ignored. + * + * @awsuiSystem core + */ + nativeTextareaAttributes?: NativeAttributes>; + /** * @awsuiSystem core */ @@ -129,12 +171,105 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; + export interface TextInputToken { + type: 'text'; + text: string; + } + + export interface ReferenceInputToken { + type: 'reference'; + id: string; + label: string; + value: string; + } + + export type InputToken = TextInputToken | ReferenceInputToken; + export interface ChangeDetail { - value: React.ReactNode; + value?: string; + tokens?: InputToken[]; } export interface ActionDetail { - value: React.ReactNode; + value?: string; + tokens?: InputToken[]; + } + + export interface MenuDefinition { + id: string; + trigger: string; + onSelect: (option: OptionDefinition) => void; + options: OptionDefinition[]; + useAtStart?: boolean; + /** + * Determines how filtering is applied to the list of `options`: + * + * * `auto` - The component will automatically filter options based on user input. + * * `manual` - You will set up `onLoadItems` event listener and filter options on your side or request + * them from server. + * + * By default the component will filter the provided `options` based on the value of the filtering input. + * Only options that have a `value`, `label`, `description` or `labelTag` that contains the input value as a substring + * are displayed in the list of options. + * + * If you set this property to `manual`, this default filtering mechanism is disabled and all provided `options` are + * displayed in the dropdown menu. In that case make sure that you use the `onLoadItems` event in order + * to set the `options` property to the options that are relevant for the user, given the filtering input value. + * + * Note: Manual filtering doesn't disable match highlighting. + **/ + filteringType?: OptionsFilteringType; + /** + * Use this event to implement the asynchronous behavior for the menu. + * + * The event is called in the following situations: + * * The user scrolls to the end of the list of options, if `statusType` is set to `pending`. + * * The user clicks on the recovery button in the error state. + * * The user types after the trigger character. + * * The menu is opened. + * + * The detail object contains the following properties: + * * `filteringText` - The value that you need to use to fetch options. + * * `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. + * * `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). + **/ + onLoadItems?: NonCancelableEventHandler; + /** + * Displayed when there are no options to display. + * This is only shown when `statusType` is set to `finished` or not set at all. + */ + empty?: React.ReactNode; + /** + * Specifies the text to display when in the loading state. + **/ + loadingText?: string; + /** + * Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. + **/ + finishedText?: string; + /** + * Specifies the text to display when a data fetching error occurs. Make sure that you provide `recoveryText`. + **/ + errorText?: string; + /** + * Specifies the text for the recovery button. The text is displayed next to the error text. + * Use the `onLoadItems` event to perform a recovery action (for example, retrying the request). + * @i18n + **/ + recoveryText?: string; + /** + * Provides a text alternative for the error icon in the error message. + * @i18n + */ + errorIconAriaLabel?: string; + /** + * Specifies the current status of loading more options. + * * `pending` - Indicates that no request in progress, but more options may be loaded. + * * `loading` - Indicates that data fetching is in progress. + * * `finished` - Indicates that pagination has finished and no more requests are expected. + * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. + **/ + statusType?: DropdownStatusProps.StatusType; } export interface Ref { @@ -147,6 +282,15 @@ export namespace PromptInputProps { * Selects all text in the textarea control. */ select(): void; + + /** + * Selects a range of text in the textarea control. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement/setSelectionRange + * for more details on this method. Be aware that using this method in React has some + * common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-setselectionrange-incompatible-with-react-hooks + */ + setSelectionRange(start: number | null, end: number | null, direction?: 'forward' | 'backward' | 'none'): void; } export interface Style { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index c897821ed1..c41ca3f6a5 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -7,13 +7,15 @@ import clsx from 'clsx'; import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; +import { convertAutoComplete } from '../input/utils'; import { getBaseProps } from '../internal/base-component'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; -import * as tokens from '../internal/generated/styles/tokens'; +import * as designTokens from '../internal/generated/styles/tokens'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { SomeRequired } from '../internal/types'; +import WithNativeAttributes from '../internal/utils/with-native-attributes'; import Token from '../token/internal'; import { PromptInputProps } from './interfaces'; import { getPromptInputStyles } from './styles'; @@ -23,36 +25,11 @@ import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; /** - * Helper to render a Token element into a non-editable container. - * If beforeNode is provided, inserts before it; otherwise appends to parent. + * Renders tokens array into a contentEditable element. + * Text tokens are rendered as text nodes, reference tokens as Token components. */ -function renderTokenToDOM( - tokenElement: React.ReactElement, - parent: HTMLElement, - reactContainers: Set, - beforeNode?: Node | null -): HTMLElement { - const container = document.createElement('span'); - container.style.display = 'inline'; - container.contentEditable = 'false'; - - if (beforeNode) { - parent.insertBefore(container, beforeNode); - } else { - parent.appendChild(container); - } - reactContainers.add(container); - ReactDOM.render(tokenElement, container); - - return container; -} - -/** - * Renders React content (strings and Tokens) into a contentEditable element. - * Tokens are wrapped in non-editable spans with data-token-value attributes. - */ -function renderReactContentToDOM( - content: React.ReactNode, +function renderTokensToDOM( + tokens: readonly PromptInputProps.InputToken[], targetElement: HTMLElement, reactContainers: Set ): void { @@ -61,33 +38,23 @@ function renderReactContentToDOM( reactContainers.clear(); targetElement.innerHTML = ''; - const renderNode = (node: React.ReactNode, parent: HTMLElement) => { - // Primitives: render as text - if (typeof node === 'string' || typeof node === 'number') { - parent.appendChild(document.createTextNode(String(node))); - return; + tokens.forEach(token => { + if (token.type === 'text') { + targetElement.appendChild(document.createTextNode(token.text)); + } else if (token.type === 'reference') { + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + container.setAttribute('data-token-id', token.id); + container.setAttribute('data-token-type', 'reference'); + container.setAttribute('data-token-value', token.value); + + targetElement.appendChild(container); + reactContainers.add(container); + + ReactDOM.render(, container); } - - // Arrays: render each child - if (Array.isArray(node)) { - node.forEach(child => renderNode(child, parent)); - return; - } - - // React elements: Token or Fragment - const element = node as React.ReactElement; - - // Fragment: render children directly - if (element.type === React.Fragment) { - renderNode(element.props?.children, parent); - return; - } - - // Token: render in non-editable span - renderTokenToDOM(element, parent, reactContainers); - }; - - renderNode(content, targetElement); + }); // Ensure cursor can be placed at end if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { @@ -96,36 +63,49 @@ function renderReactContentToDOM( } /** - * Converts DOM back to ReactNode, preserving Tokens with their labels and values. - * Extracts the label from DOM text content and value from data-token-value attribute. + * Extracts tokens array from contentEditable DOM. + * Converts text nodes to TextInputToken and Token components to ReferenceInputToken. */ -function domToReactNode(node: Node): React.ReactNode { - // Element with data-token-value: convert to Token - if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as HTMLElement; - const tokenValue = element.getAttribute('data-token-value'); - if (tokenValue) { - // Extract label from the DOM text content (rendered by Token component) - const label = element.textContent || ''; - return ; +function domToTokenArray(element: HTMLElement): PromptInputProps.InputToken[] { + const tokens: PromptInputProps.InputToken[] = []; + let currentText = ''; + + const flushText = () => { + if (currentText) { + tokens.push({ type: 'text', text: currentText }); + currentText = ''; } - } - - // Text node: return text content - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent || ''; - } + }; - // Other elements: recursively convert children - const children: React.ReactNode[] = []; - for (const child of Array.from(node.childNodes)) { - const childNode = domToReactNode(child); - if (childNode) { - children.push(childNode); + const processNode = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + currentText += node.textContent || ''; + } else if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + const tokenId = element.getAttribute('data-token-id'); + const tokenType = element.getAttribute('data-token-type'); + + if (tokenType === 'reference' && tokenId) { + flushText(); + const label = element.textContent || ''; + const value = element.getAttribute('data-token-value') || label; + tokens.push({ + type: 'reference', + id: tokenId, + label, + value, + }); + } else { + // Process children for other elements + Array.from(node.childNodes).forEach(processNode); + } } - } + }; + + Array.from(element.childNodes).forEach(processNode); + flushText(); - return children.length === 0 ? '' : children.length === 1 ? children[0] : <>{children}; + return tokens; } interface InternalPromptInputProps @@ -143,6 +123,7 @@ const InternalPromptInput = React.forwardRef( actionButtonIconAlt, ariaLabel, autoFocus, + autoComplete, disableActionButton, disableBrowserAutocorrect, disabled, @@ -163,7 +144,12 @@ const InternalPromptInput = React.forwardRef( secondaryContent, disableSecondaryActionsPaddings, disableSecondaryContentPaddings, + nativeTextareaAttributes, style, + // Shortcuts + tokens, + menus, + activeMenuId, __internalRootRef, ...rest }: InternalPromptInputProps, @@ -173,38 +159,133 @@ const InternalPromptInput = React.forwardRef( const baseProps = getBaseProps(rest); + const textareaRef = useRef(null); const editableElementRef = useRef(null); + const reactContainersRef = useRef>(new Set()); const isRenderingRef = useRef(false); - const lastValueRef = useRef(value); + const lastTokensRef = useRef(tokens); + const savedSelectionRef = useRef(null); const isRefresh = useVisualRefresh(); - const isCompactMode = useDensityMode(editableElementRef) === 'compact'; + const isTextAreaCompactMode = useDensityMode(textareaRef) === 'compact'; + const isEditableElementCompactMode = useDensityMode(editableElementRef) === 'compact'; + const isCompactMode = isTextAreaCompactMode || isEditableElementCompactMode; + const isMenusEnabled = tokens || menus; - const PADDING = isRefresh ? tokens.spaceXxs : tokens.spaceXxxs; - const LINE_HEIGHT = tokens.lineHeightBodyM; + const PADDING = isRefresh ? designTokens.spaceXxs : designTokens.spaceXxxs; + const LINE_HEIGHT = designTokens.lineHeightBodyM; const DEFAULT_MAX_ROWS = 3; + useEffect(() => { + console.log('Active menu is:', activeMenuId); + }, [activeMenuId]); + useImperativeHandle( ref, () => ({ focus(...args: Parameters) { - editableElementRef.current?.focus(...args); + if (isMenusEnabled) { + editableElementRef.current?.focus(...args); + // Restore saved selection if available + if (savedSelectionRef.current) { + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(savedSelectionRef.current.cloneRange()); + } + } else { + textareaRef.current?.focus(...args); + } }, select() { - const selection = window.getSelection(); - const range = document.createRange(); - if (editableElementRef.current) { - range.selectNodeContents(editableElementRef.current); - selection?.removeAllRanges(); - selection?.addRange(range); + if (isMenusEnabled) { + const selection = window.getSelection(); + const range = document.createRange(); + if (editableElementRef.current) { + range.selectNodeContents(editableElementRef.current); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } else { + textareaRef.current?.select(); + } + }, + setSelectionRange(...args: Parameters) { + if (isMenusEnabled) { + if (!editableElementRef.current) { + return; + } + + const [start, end] = args; + const selection = window.getSelection(); + if (!selection) { + return; + } + + // Helper to find node and offset for a given character position + // Reference tokens count as 1 character each + const findNodeAndOffset = (targetOffset: number): { node: Node; offset: number } | null => { + let currentOffset = 0; + + for (const child of Array.from(editableElementRef.current!.childNodes)) { + // Reference token element - counts as 1 character + if ( + child.nodeType === Node.ELEMENT_NODE && + (child as HTMLElement).getAttribute('data-token-type') === 'reference' + ) { + if (currentOffset === targetOffset) { + return { node: child, offset: 0 }; + } + currentOffset += 1; + continue; + } + + // Text node + if (child.nodeType === Node.TEXT_NODE) { + const textLength = child.textContent?.length || 0; + if (currentOffset + textLength >= targetOffset) { + return { node: child, offset: targetOffset - currentOffset }; + } + currentOffset += textLength; + } + } + + // Return last position if not found + const lastChild = editableElementRef.current!.lastChild; + return lastChild ? { node: lastChild, offset: lastChild.textContent?.length || 0 } : null; + }; + + const startPos = findNodeAndOffset(start ?? 0); + const endPos = findNodeAndOffset(end ?? 0); + + if (startPos && endPos) { + const range = document.createRange(); + range.setStart(startPos.node, startPos.offset); + range.setEnd(endPos.node, endPos.offset); + selection.removeAllRanges(); + selection.addRange(range); + } + } else { + textareaRef.current?.setSelectionRange(...args); } }, }), - [editableElementRef] + [isMenusEnabled, textareaRef, editableElementRef] ); - const handleKeyDown: React.KeyboardEventHandler = event => { + const handleTextareaKeyDown = (event: React.KeyboardEvent) => { + fireKeyboardEvent(onKeyDown, event); + + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + if (event.currentTarget.form && !event.isDefaultPrevented()) { + event.currentTarget.form.requestSubmit(); + } + event.preventDefault(); + fireNonCancelableEvent(onAction, { value, tokens: [...(tokens ?? [])] }); + } + }; + + const handleEditableElementKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { @@ -213,12 +294,11 @@ const InternalPromptInput = React.forwardRef( form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value }); + fireNonCancelableEvent(onAction, { value, tokens: [...(tokens ?? [])] }); } - // Detect space key for @mention conversion (prototype feature) + // Detect space key for @mention and /command conversion (prototype feature) if (event.key === ' ' && editableElementRef.current) { - // Check if there's an @mention pattern before the cursor const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); @@ -226,30 +306,48 @@ const InternalPromptInput = React.forwardRef( if (textNode.nodeType === Node.TEXT_NODE && textNode.textContent) { const textBeforeCursor = textNode.textContent.substring(0, range.startOffset); + + // Check for @mention pattern (can appear anywhere after whitespace) const mentionMatch = textBeforeCursor.match(/(^|\s)@(\w+)$/); + // Check for /command pattern (only at the very start) + const commandMatch = textBeforeCursor.match(/^\/(\w+)$/); + + const match = commandMatch || mentionMatch; + const isCommand = !!commandMatch; - if (mentionMatch) { - // Prevent default space insertion + if (match) { event.preventDefault(); - const mentionText = mentionMatch[2]; - const beforeMention = textBeforeCursor.substring(0, mentionMatch.index! + mentionMatch[1].length); + const matchText = match[2] || match[1]; // command uses [1], mention uses [2] + const beforeMatch = isCommand ? '' : textBeforeCursor.substring(0, match.index! + match[1].length); const afterCursor = textNode.textContent.substring(range.startOffset); - // Create the full token value (will come from menu selection in the future) - const tokenValue = `${mentionText}`; + // Create reference token + const tokenId = isCommand ? `command:${matchText}` : `file:${matchText}`; + const tokenValue = isCommand + ? `${matchText}` + : `${matchText}`; - // Replace text with: before + Token + space + after const parent = textNode.parentNode as HTMLElement; - const beforeText = document.createTextNode(beforeMention); + const beforeText = document.createTextNode(beforeMatch); const spaceText = document.createTextNode(' '); const afterText = document.createTextNode(afterCursor); - parent.insertBefore(beforeText, textNode); + if (beforeMatch) { + parent.insertBefore(beforeText, textNode); + } + + // Render reference token + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + container.setAttribute('data-token-id', tokenId); + container.setAttribute('data-token-type', 'reference'); + container.setAttribute('data-token-value', tokenValue); - // Render Token using helper (insert before textNode) - const tokenElement = ; - renderTokenToDOM(tokenElement, parent, reactContainersRef.current, textNode); + parent.insertBefore(container, textNode); + reactContainersRef.current.add(container); + ReactDOM.render(, container); parent.insertBefore(spaceText, textNode); parent.insertBefore(afterText, textNode); @@ -271,50 +369,64 @@ const InternalPromptInput = React.forwardRef( } }; - const handleChange: React.FormEventHandler = () => { + const handleChangeTextArea = (event: React.ChangeEvent) => { + fireNonCancelableEvent(onChange, { value: event.target.value, tokens: [...(tokens ?? [])] }); + adjustInputHeight(); + }; + + const handleChangeEditableElement: React.FormEventHandler = () => { if (isRenderingRef.current) { return; // Skip onChange during programmatic rendering } - // Convert DOM back to ReactNode and emit to consumer if (!editableElementRef.current) { return; } - const reactNodeValue = domToReactNode(editableElementRef.current); - lastValueRef.current = reactNodeValue; // Track the value we're emitting - fireNonCancelableEvent(onChange, { value: reactNodeValue }); - adjustTextareaHeight(); + + // Extract tokens from DOM + const extractedTokens = domToTokenArray(editableElementRef.current); + const plainText = getPromptText(extractedTokens); + + // Update ref to track that this change came from user input + lastTokensRef.current = extractedTokens; + + fireNonCancelableEvent(onChange, { value: plainText, tokens: extractedTokens }); + adjustInputHeight(); }; const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; - const adjustTextareaHeight = useCallback(() => { - if (editableElementRef.current) { - const element = editableElementRef.current; + const adjustInputHeight = useCallback(() => { + if (editableElementRef.current || textareaRef.current) { + const element = isMenusEnabled ? editableElementRef.current : textareaRef.current; // Save scroll position before adjusting height - const scrollTop = element.scrollTop; + const scrollTop = element!.scrollTop; // this is required so the scrollHeight becomes dynamic, otherwise it will be locked at the highest value for the size it reached e.g. 500px - element.style.height = 'auto'; + element!.style.height = 'auto'; - const minRowsHeight = `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; - const scrollHeight = `calc(${element.scrollHeight}px)`; + const minRowsHeight = isMenusEnabled + ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})` + : `calc(${LINE_HEIGHT} + ${designTokens.spaceScaledXxs} * 2)`; + const scrollHeight = `calc(${element!.scrollHeight}px)`; if (maxRows === -1) { - element.style.height = `max(${scrollHeight}, ${minRowsHeight})`; + element!.style.height = `max(${scrollHeight}, ${minRowsHeight})`; } else { const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; - element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; + element!.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; } - // Restore scroll position after adjusting height - element.scrollTop = scrollTop; + if (isMenusEnabled) { + // Restore scroll position after adjusting height + element!.scrollTop = scrollTop; + } } - }, [maxRows, minRows, LINE_HEIGHT, PADDING]); + }, [isMenusEnabled, minRows, LINE_HEIGHT, PADDING, maxRows]); useEffect(() => { const handleResize = () => { - adjustTextareaHeight(); + adjustInputHeight(); }; window.addEventListener('resize', handleResize); @@ -322,28 +434,40 @@ const InternalPromptInput = React.forwardRef( return () => { window.removeEventListener('resize', handleResize); }; - }, [adjustTextareaHeight]); + }, [adjustInputHeight]); - // Render value prop into contentEditable only when it changes externally + // Render tokens into contentEditable when they change externally (not from user typing) useEffect(() => { - if (!editableElementRef.current) { - return; - } + if (isMenusEnabled && editableElementRef.current && tokens) { + // Only re-render if tokens changed from outside (not from user input) + if (tokens !== lastTokensRef.current) { + isRenderingRef.current = true; + renderTokensToDOM(tokens, editableElementRef.current, reactContainersRef.current); + isRenderingRef.current = false; + lastTokensRef.current = tokens; + + // Position cursor at the end of the content when tokens are set externally + requestAnimationFrame(() => { + if (!editableElementRef.current) { + return; + } - // Only re-render if the value actually changed from outside - // (not from user typing which would have updated lastValueRef) - if (value !== lastValueRef.current) { - isRenderingRef.current = true; - renderReactContentToDOM(value || '', editableElementRef.current, reactContainersRef.current); - isRenderingRef.current = false; - lastValueRef.current = value; + // Position cursor at the end of the editable element + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(editableElementRef.current); + range.collapse(false); // false = collapse to end + selection?.removeAllRanges(); + selection?.addRange(range); + }); + } } - adjustTextareaHeight(); - }, [value, adjustTextareaHeight, maxRows, isCompactMode]); + adjustInputHeight(); + }, [isMenusEnabled, tokens, value, adjustInputHeight, maxRows, isCompactMode]); useEffect(() => { - if (autoFocus && editableElementRef.current) { + if (isMenusEnabled && autoFocus && editableElementRef.current) { editableElementRef.current.focus(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -360,10 +484,39 @@ const InternalPromptInput = React.forwardRef( }; }, []); - // Determine if placeholder should be visible (only when value is empty) - const showPlaceholder = !value && placeholder; + const textareaAttributes: React.TextareaHTMLAttributes = { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + 'aria-invalid': invalid ? 'true' : undefined, + name, + placeholder, + autoFocus, + className: clsx(styles.textarea, testutilStyles.textarea, { + [styles.invalid]: invalid, + [styles.warning]: warning, + }), + autoComplete: convertAutoComplete(autoComplete), + spellCheck: spellcheck, + disabled, + readOnly: readOnly ? true : undefined, + rows: minRows, + onKeyDown: handleTextareaKeyDown, + onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), + // We set a default value on the component in order to force it into the controlled mode. + value: value || '', + onChange: handleChangeTextArea, + onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), + onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), + }; + + // Determine if placeholder should be visible + const showEditableElementPlaceholder = + isMenusEnabled && + placeholder && + (!tokens || tokens.length === 0 || (tokens.length === 1 && tokens[0].type === 'text' && !tokens[0].text)); - const attributes: React.HTMLAttributes & { + const editableElementAttributes: React.HTMLAttributes & { 'data-placeholder'?: string; } = { 'aria-label': ariaLabel, @@ -378,19 +531,32 @@ const InternalPromptInput = React.forwardRef( [styles.invalid]: invalid, [styles.warning]: warning, [styles['textarea-disabled']]: disabled, - [styles['placeholder-visible']]: showPlaceholder, + [styles['placeholder-visible']]: showEditableElementPlaceholder, }), spellCheck: spellcheck, - onKeyDown: handleKeyDown, + onKeyDown: handleEditableElementKeyDown, onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - onInput: handleChange, - onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), + onInput: handleChangeEditableElement, + onBlur: () => { + // Save selection before blur for contentEditable + if (isMenusEnabled) { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + savedSelectionRef.current = selection.getRangeAt(0); + } + } + if (onBlur) { + fireNonCancelableEvent(onBlur); + } + }, onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; if (disableBrowserAutocorrect) { - attributes.autoCorrect = 'off'; - attributes.autoCapitalize = 'off'; + textareaAttributes.autoCorrect = 'off'; + textareaAttributes.autoCapitalize = 'off'; + editableElementAttributes.autoCorrect = 'off'; + editableElementAttributes.autoCapitalize = 'off'; } const action = ( @@ -405,7 +571,7 @@ const InternalPromptInput = React.forwardRef( iconUrl={actionButtonIconUrl} iconSvg={actionButtonIconSvg} iconAlt={actionButtonIconAlt} - onClick={() => fireNonCancelableEvent(onAction, { value })} + onClick={() => fireNonCancelableEvent(onAction, { value, tokens: [...(tokens ?? [])] })} variant="icon" /> )} @@ -438,15 +604,28 @@ const InternalPromptInput = React.forwardRef(
)}
- {name && } -
+ {tokens ? ( + <> + {name && } +
+ + ) : ( + + )} {hasActionButton && !secondaryActions && action}
{secondaryActions && ( @@ -466,7 +645,10 @@ const InternalPromptInput = React.forwardRef( > {secondaryActions}
-
editableElementRef.current?.focus()} /> +
(isMenusEnabled ? editableElementRef.current?.focus() : textareaRef.current?.focus())} + /> {hasActionButton && action}
)} diff --git a/src/prompt-input/utils.tsx b/src/prompt-input/utils.tsx index 3c412c59dd..b2db8bd864 100644 --- a/src/prompt-input/utils.tsx +++ b/src/prompt-input/utils.tsx @@ -1,44 +1,39 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -import Token from '../token/internal'; +import { PromptInputProps } from './interfaces'; /** - * Extracts text from a PromptInput value. + * Extracts text from a PromptInput token array. * By default, returns full token values (for form submission). * Set labelsOnly=true to get just the visible labels (for UI display/counting). * - * @param value - The PromptInput value (ReactNode) + * @param tokens - The PromptInput token array * @param labelsOnly - If true, returns only visible text; if false, returns full token values * * @example - * const value = <>Hello ; - * getPromptText(value); // "Hello user" - * getPromptText(value, true); // "Hello user" + * const tokens = [ + * { type: 'text', text: 'Hello ' }, + * { type: 'reference', id: 'file:user', label: 'user', value: 'user' } + * ]; + * getPromptText(tokens); // "Hello user" + * getPromptText(tokens, true); // "Hello user" */ -export function getPromptText(value: React.ReactNode, labelsOnly = false): string { - if (!value) { +export function getPromptText(tokens: readonly PromptInputProps.InputToken[], labelsOnly = false): string { + if (!tokens) { return ''; } - if (typeof value === 'string') { - return value; - } - if (typeof value === 'number') { - return String(value); - } - if (Array.isArray(value)) { - return value.map(child => getPromptText(child, labelsOnly)).join(''); - } - // React element (Token or Fragment) - const element = value as React.ReactElement; - if (element.type === Token) { - // Return label or full value based on labelsOnly flag - return labelsOnly ? element.props.label || '' : element.props.value || element.props.label || ''; - } - return getPromptText(element.props.children, labelsOnly); + return tokens + .map(token => { + if (token.type === 'text') { + return token.text; + } else if (token.type === 'reference') { + return labelsOnly ? token.label : token.value; + } + return ''; + }) + .join(''); } export default getPromptText; From 77fc85fefc2d63f2ddd485cdb60f5cdba5cf4df8 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 4 Dec 2025 14:21:38 +0100 Subject: [PATCH 4/6] Re-add core tag to nativeAttributes import --- src/prompt-input/interfaces.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 2e730ffe7a..5c2ef39aea 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -16,6 +16,9 @@ import { DropdownStatusProps } from '../internal/components/dropdown-status'; import { OptionDefinition } from '../internal/components/option/interfaces'; import { FormFieldValidationControlProps } from '../internal/context/form-field-context'; import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; +/** + * @awsuiSystem core + */ import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps From 84f723d276ce95cff04b503d625110f0f088f203 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Tue, 9 Dec 2025 15:55:22 +0100 Subject: [PATCH 5/6] Clean-up and polishing of interfaces and utils --- pages/prompt-input/simple.page.tsx | 87 ++-- src/prompt-input/interfaces.ts | 253 ++++++++---- src/prompt-input/internal.tsx | 621 +++++++++++++--------------- src/prompt-input/selection-utils.ts | 70 ++++ src/prompt-input/token-utils.tsx | 238 +++++++++++ src/prompt-input/utils.tsx | 39 -- 6 files changed, 818 insertions(+), 490 deletions(-) create mode 100644 src/prompt-input/selection-utils.ts create mode 100644 src/prompt-input/token-utils.tsx delete mode 100644 src/prompt-input/utils.tsx diff --git a/pages/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx index e539466abe..1e3a836171 100644 --- a/pages/prompt-input/simple.page.tsx +++ b/pages/prompt-input/simple.page.tsx @@ -16,7 +16,6 @@ import { SpaceBetween, SplitPanel, } from '~components'; -import getPromptText from '~components/prompt-input/utils'; import AppContext, { AppContextType } from '../app/app-context'; import labels from '../app-layout/utils/labels'; @@ -50,10 +49,11 @@ const placeholderText = export default function PromptInputPage() { const [textareaValue, setTextareaValue] = useState(''); const [tokens, setTokens] = useState([]); + const [mode, setMode] = useState(); + const [plainTextValue, setPlainTextValue] = useState(''); const [valueInSplitPanel, setValueInSplitPanel] = useState(''); const [files, setFiles] = useState([]); const [extractedText, setExtractedText] = useState(''); - const [lastCommandToken, setLastCommandToken] = useState(null); const [selectionStart, setSelectionStart] = useState('0'); const [selectionEnd, setSelectionEnd] = useState('0'); @@ -86,7 +86,7 @@ export default function PromptInputPage() { useEffect(() => { if (hasText) { if (enableReferences) { - setTokens([{ type: 'text', text: placeholderText }]); + setTokens([{ type: 'text', value: placeholderText }]); } else { setTextareaValue(placeholderText); } @@ -94,18 +94,12 @@ export default function PromptInputPage() { }, [hasText, enableReferences]); useEffect(() => { - if (enableReferences) { - const plainText = getPromptText(tokens); - if (plainText !== placeholderText) { - setUrlParams({ hasText: false }); - } - } else { - if (textareaValue !== placeholderText) { - setUrlParams({ hasText: false }); - } + const currentValue = enableReferences ? plainTextValue : textareaValue; + if (currentValue !== placeholderText) { + setUrlParams({ hasText: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [textareaValue, tokens, enableReferences]); + }, [textareaValue, plainTextValue, enableReferences]); useEffect(() => { if (items.length === 0) { @@ -251,6 +245,7 @@ export default function PromptInputPage() { // Reset values when switching modes setTextareaValue(''); setTokens([]); + setPlainTextValue(''); }} > Enable references (tokens mode - supports @mentions and /commands) @@ -306,7 +301,7 @@ export default function PromptInputPage() { {extractedText && (
- Last extracted text (using getPromptText): + Last submitted text: {extractedText} @@ -325,6 +320,17 @@ export default function PromptInputPage() { )} + {enableReferences && mode && ( + +
+ Current mode: + + {JSON.stringify(mode, null, 2)} + +
+
+ )} + { event.preventDefault(); @@ -337,15 +343,14 @@ export default function PromptInputPage() { MAX_CHARS || - isInvalid) && + ((enableReferences ? plainTextValue.length : textareaValue.length) > MAX_CHARS || isInvalid) && 'The query has too many characters.' } warningText={hasWarning && 'This input has a warning'} constraintText={ <> This service is subject to some policy. Character count:{' '} - {enableReferences ? getPromptText(tokens, true).length : textareaValue.length}/{MAX_CHARS} + {enableReferences ? plainTextValue.length : textareaValue.length}/{MAX_CHARS} {enableReferences && ' (Token mode: type @word or /command and press space)'} } @@ -359,50 +364,36 @@ export default function PromptInputPage() { actionButtonAriaLabel="Submit prompt" value={enableReferences ? undefined : textareaValue} tokens={enableReferences ? tokens : undefined} + mode={enableReferences ? mode : undefined} + onModeRemoved={() => { + setMode(undefined); + }} onChange={(event: any) => { if (enableReferences) { - const newTokens = event.detail.tokens; - setTokens(newTokens); + setTokens(event.detail.tokens); + setPlainTextValue(event.detail.value ?? ''); - // Check if user manually removed the command token - const hasCommandToken = newTokens.some( - (token: PromptInputProps.InputToken) => - token.type === 'reference' && token.id.startsWith('command:') - ); - if (!hasCommandToken && lastCommandToken) { - // User removed the command, clear it so it doesn't come back - setLastCommandToken(null); + // Update mode if it changed + if (event.detail.mode !== undefined) { + setMode(event.detail.mode); } } else { - setTextareaValue(event.detail.value); + setTextareaValue(event.detail.value ?? ''); } }} onAction={({ detail }) => { - const plainText = enableReferences ? getPromptText(detail.tokens ?? []) : detail.value; - - setExtractedText(plainText ?? ''); + setExtractedText(detail.value ?? ''); if (enableReferences) { - // Find command token in submitted tokens - const commandToken = detail.tokens?.find( - (token: PromptInputProps.InputToken) => - token.type === 'reference' && token.id.startsWith('command:') - ) as PromptInputProps.ReferenceInputToken | undefined; - - if (commandToken) { - // Save command token and restore it with a space after clearing - setLastCommandToken(commandToken); - setTokens([commandToken, { type: 'text', text: ' ' }]); - } else { - // No command token, clear everything - setTokens([]); - } + // Clear tokens after submission, but keep the mode + setTokens([]); + setPlainTextValue(''); } else { setTextareaValue(''); } window.alert( - `Submitted:\n\nPlain text: ${plainText}\n\n${ + `Submitted:\n\nPlain text: ${detail.value ?? ''}\n\n${ enableReferences ? `Tokens: ${JSON.stringify(detail.tokens, null, 2)}` : '' }` ); @@ -413,9 +404,7 @@ export default function PromptInputPage() { readOnly={isReadOnly} invalid={ isInvalid || - (enableReferences - ? getPromptText(tokens, true).length > MAX_CHARS - : textareaValue.length > MAX_CHARS) + (enableReferences ? plainTextValue.length > MAX_CHARS : textareaValue.length > MAX_CHARS) } warning={hasWarning} ref={ref} diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 5c2ef39aea..e0e297ac2b 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; +import { AutosuggestProps } from '../autosuggest/interfaces'; import { IconProps } from '../icon/interfaces'; import { BaseInputProps, @@ -11,7 +12,11 @@ import { InputSpellcheck, } from '../input/interfaces'; import { BaseComponentProps } from '../internal/base-component'; -import { OptionsFilteringType, OptionsLoadItemsDetail } from '../internal/components/dropdown/interfaces'; +import { + BaseDropdownHostProps, + OptionsFilteringType, + OptionsLoadItemsDetail, +} from '../internal/components/dropdown/interfaces'; import { DropdownStatusProps } from '../internal/components/dropdown-status'; import { OptionDefinition } from '../internal/components/option/interfaces'; import { FormFieldValidationControlProps } from '../internal/context/form-field-context'; @@ -22,46 +27,89 @@ import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps - extends Omit, + extends Omit, InputKeyEvents, InputAutoCorrect, InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { + /** + * Specifies the name of the prompt input for form submissions. + * + * When `tokens` is set, the value will be the `tokensToText` output if provided, + * else it will be the concatenated `value` properties from `tokens`. + */ + name?: string; + /** * Specifies the content of the prompt input, not in use if `tokens` is defined. */ value?: string; /** - * Specifies the content of the prompt input when modes/references are in use. - * Represented as an array of TextToken or ReferenceToken. + * Specifies the content of the prompt input when using token mode. + * + * All tokens use the same unified structure with a `value` property: + * - Text tokens: `value` contains the text content + * - Reference tokens: `value` contains the reference value, `label` for display (e.g., '@john') + * - Mode tokens: Use the `mode` prop instead of including in this array + * * When defined, `autocomplete` will no longer function. */ tokens?: readonly PromptInputProps.InputToken[]; + /** + * Specifies the active mode (e.g., /dev, /creative). + * Set `type` to `mode`. + */ + mode?: PromptInputProps.InputToken; + + /** + * Called when the user removes the active mode. + */ + onModeRemoved?: NonCancelableEventHandler; + + /** + * Custom function to transform tokens into plain text for the `value` field in `onChange` and `onAction` events + * and for the hidden input when `name` is specified. + * + * If not provided, the default implementation concatenates the `value` property from all tokens. + * + * Use this to customize serialization, for example: + * - Using `label` instead of `value` for reference tokens + * - Adding custom formatting or separators between tokens + */ + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + /** * Called whenever a user changes the input value (by typing or pasting). * The event `detail` contains the current value as a React.ReactNode. + * + * When `tokens` is defined this will return `undefined` for `value` and an array of `tokens` representing the current content in the input. */ onChange?: NonCancelableEventHandler; /** * Called whenever a user clicks the action button or presses the "Enter" key. * The event `detail` contains the current value of the field. + * + * When `tokens` is defined this will return `undefined` for `value` and an array of `tokens` representing the current content in the input. */ onAction?: NonCancelableEventHandler; + /** * Determines what icon to display in the action button. */ actionButtonIconName?: IconProps.Name; + /** * Specifies the URL of a custom icon. Use this property if the icon you want isn't available. * * If you set both `actionButtonIconUrl` and `actionButtonIconSvg`, `actionButtonIconSvg` will take precedence. */ actionButtonIconUrl?: string; + /** * Specifies the SVG of a custom icon. * @@ -84,11 +132,13 @@ export interface PromptInputProps * In most cases, they aren't needed, as the `svg` element inherits styles from the icon component. */ actionButtonIconSvg?: React.ReactNode; + /** * Specifies alternate text for a custom icon. We recommend that you provide this for accessibility. * This property is ignored if you use a predefined icon or if you set your custom icon using the `iconSvg` slot. */ actionButtonIconAlt?: string; + /** * Adds an aria-label to the action button. * @i18n @@ -141,16 +191,58 @@ export interface PromptInputProps disableSecondaryContentPaddings?: boolean; /** - * Menus that can be triggered via shortcuts (e.g., "/" or "@"). + * Menus that can be triggered via specific symbols (e.g., "/" or "@"). * For menus only relevant to triggers at the start of the input, set `useAtStart: true`, defaults to `false`. */ menus?: PromptInputProps.MenuDefinition[]; /** - * The ID of the menu to show. When undefined, no menu is shown. - * If undefined then the input can show a menu based depending on the shortcut trigger. + * Called whenever a user selects an option in the menu. + */ + onMenuSelect?: NonCancelableEventHandler; + + /** + * Use this event to implement the asynchronous behavior for the menu. + * + * The event is called in the following situations: + * - The user scrolls to the end of the list of options, if `statusType` is set to `pending`. + * - The user clicks on the recovery button in the error state. + * - The user types after the trigger character. + * - The menu is opened. + * + * The detail object contains the following properties: + * - `filteringText` - The value that you need to use to fetch options. + * - `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. + * - `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). + */ + onMenuLoadItems?: NonCancelableEventHandler; + + /** + * Provides a text alternative for the error icon in the error message in menus. + * @i18n */ - activeMenuId?: string; + menuErrorIconAriaLabel?: string; + + /** + * Specifies the localized string that describes an option as being selected. + * This is required to provide a good screen reader experience. For more information, see the + * [accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines). + * @i18n + */ + selectedMenuItemAriaLabel?: string; + + /** + * Overrides the element that is announced to screen readers in menus + * when the highlighted option changes. By default, this announces + * the option's name and properties, and its selected state if + * the `selectedLabel` property is defined. + * The highlighted option is provided, and its group (if groups + * are used and it differs from the group of the previously highlighted option). + * + * For more information, see the + * [accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines). + */ + renderHighlightedMenuItemAriaLive?: AutosuggestProps.ContainingOptionAndGroupString; /** * Attributes to add to the native `textarea` element. @@ -174,23 +266,21 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; - export interface TextInputToken { - type: 'text'; - text: string; - } - - export interface ReferenceInputToken { - type: 'reference'; - id: string; - label: string; - value: string; + export interface InputToken { + type: 'text' | 'reference' | 'mode'; + id?: string; + label?: string; + value?: string; } - export type InputToken = TextInputToken | ReferenceInputToken; - export interface ChangeDetail { value?: string; tokens?: InputToken[]; + /** + * @experimental Prototype feature - communicates mode detection. + * Will be replaced by menu system in final implementation. + */ + mode?: InputToken; } export interface ActionDetail { @@ -198,81 +288,96 @@ export namespace PromptInputProps { tokens?: InputToken[]; } - export interface MenuDefinition { + export interface MenuSelectDetail { + menuId: string; + option: OptionDefinition; + } + + export interface MenuLoadItemsDetail extends OptionsLoadItemsDetail { + menuId: string; + } + + export interface ModeChangeDetail { + mode?: InputToken; + } + + export interface MenuDefinition + extends Omit, + Pick { + /** + * The unique identifier for this menu. + */ id: string; + + /** + * The unique trigger symbol for showing this menu. + */ trigger: string; - onSelect: (option: OptionDefinition) => void; - options: OptionDefinition[]; + + /** + * Set `useAtStart=true` for menus where a trigger should only be detected at the start of input. + */ useAtStart?: boolean; + + /** + * Specifies an array of options that are displayed to the user as a dropdown list. + * The options can be grouped using `OptionGroup` objects. + * + * #### Option + * - `value` (string) - The returned value of the option when selected. + * - `label` (string) - (Optional) Option text displayed to the user. + * - `lang` (string) - (Optional) The language of the option, provided as a BCP 47 language tag. + * - `description` (string) - (Optional) Further information about the option that appears below the label. + * - `disabled` (boolean) - (Optional) Determines whether the option is disabled. + * - `labelTag` (string) - (Optional) A label tag that provides additional guidance, shown next to the label. + * - `tags` [string[]] - (Optional) A list of tags giving further guidance about the option. + * - `filteringTags` [string[]] - (Optional) A list of additional tags used for automatic filtering. + * - `iconName` (string) - (Optional) Specifies the name of an [icon](/components/icon/) to display in the option. + * - `iconAriaLabel` (string) - (Optional) Specifies alternate text for the icon. We recommend that you provide this for accessibility. + * - `iconAlt` (string) - (Optional) **Deprecated**, replaced by \`iconAriaLabel\`. Specifies alternate text for a custom icon, for use with `iconUrl`. + * - `iconUrl` (string) - (Optional) URL of a custom icon. + * - `iconSvg` (ReactNode) - (Optional) Custom SVG icon. Equivalent to the `svg` slot of the [icon component](/components/icon/). + * + * #### OptionGroup + * - `label` (string) - Option group text displayed to the user. + * - `disabled` (boolean) - (Optional) Determines whether the option group is disabled. + * - `options` (Option[]) - (Optional) The options under this group. + * + * Note: Only one level of option nesting is supported. + * + * If you want to use the built-in filtering capabilities of this component, provide + * a list of all valid options here and they will be automatically filtered based on the user's filtering input. + * + * Alternatively, you can listen to the `onChange` or `onLoadItems` event and set new options + * on your own. + */ + options: OptionDefinition[]; + /** * Determines how filtering is applied to the list of `options`: * * * `auto` - The component will automatically filter options based on user input. - * * `manual` - You will set up `onLoadItems` event listener and filter options on your side or request + * * `manual` - You will set up `onChange` or `onMenuLoadItems` event listeners and filter options on your side or request * them from server. * - * By default the component will filter the provided `options` based on the value of the filtering input. + * By default the component will filter the provided `options` based on the value of the filtering input field. * Only options that have a `value`, `label`, `description` or `labelTag` that contains the input value as a substring * are displayed in the list of options. * * If you set this property to `manual`, this default filtering mechanism is disabled and all provided `options` are - * displayed in the dropdown menu. In that case make sure that you use the `onLoadItems` event in order + * displayed in the dropdown list. In that case make sure that you use the `onChange` or `onMenuLoadItems` events in order * to set the `options` property to the options that are relevant for the user, given the filtering input value. * * Note: Manual filtering doesn't disable match highlighting. **/ - filteringType?: OptionsFilteringType; - /** - * Use this event to implement the asynchronous behavior for the menu. - * - * The event is called in the following situations: - * * The user scrolls to the end of the list of options, if `statusType` is set to `pending`. - * * The user clicks on the recovery button in the error state. - * * The user types after the trigger character. - * * The menu is opened. - * - * The detail object contains the following properties: - * * `filteringText` - The value that you need to use to fetch options. - * * `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. - * * `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). - **/ - onLoadItems?: NonCancelableEventHandler; - /** - * Displayed when there are no options to display. - * This is only shown when `statusType` is set to `finished` or not set at all. - */ - empty?: React.ReactNode; - /** - * Specifies the text to display when in the loading state. - **/ - loadingText?: string; - /** - * Specifies the text to display at the bottom of the dropdown menu after pagination has reached the end. - **/ - finishedText?: string; - /** - * Specifies the text to display when a data fetching error occurs. Make sure that you provide `recoveryText`. - **/ - errorText?: string; + filteringType?: Exclude; + /** * Specifies the text for the recovery button. The text is displayed next to the error text. - * Use the `onLoadItems` event to perform a recovery action (for example, retrying the request). - * @i18n - **/ - recoveryText?: string; - /** - * Provides a text alternative for the error icon in the error message. + * Use the `onMenuLoadItems` event to perform a recovery action (for example, retrying the request). * @i18n */ - errorIconAriaLabel?: string; - /** - * Specifies the current status of loading more options. - * * `pending` - Indicates that no request in progress, but more options may be loaded. - * * `loading` - Indicates that data fetching is in progress. - * * `finished` - Indicates that pagination has finished and no more requests are expected. - * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. - **/ - statusType?: DropdownStatusProps.StatusType; + recoveryText?: string; } export interface Ref { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index c41ca3f6a5..1fc970b99d 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -16,98 +16,14 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { SomeRequired } from '../internal/types'; import WithNativeAttributes from '../internal/utils/with-native-attributes'; -import Token from '../token/internal'; import { PromptInputProps } from './interfaces'; +import { findNodeAndOffset, restoreSelection, saveSelection, setCursorToEnd } from './selection-utils'; import { getPromptInputStyles } from './styles'; -import { getPromptText } from './utils'; +import { domToTokenArray, getPromptText, renderTokensToDOM } from './token-utils'; import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; -/** - * Renders tokens array into a contentEditable element. - * Text tokens are rendered as text nodes, reference tokens as Token components. - */ -function renderTokensToDOM( - tokens: readonly PromptInputProps.InputToken[], - targetElement: HTMLElement, - reactContainers: Set -): void { - // Clean up previous render - reactContainers.forEach(container => ReactDOM.unmountComponentAtNode(container)); - reactContainers.clear(); - targetElement.innerHTML = ''; - - tokens.forEach(token => { - if (token.type === 'text') { - targetElement.appendChild(document.createTextNode(token.text)); - } else if (token.type === 'reference') { - const container = document.createElement('span'); - container.style.display = 'inline'; - container.contentEditable = 'false'; - container.setAttribute('data-token-id', token.id); - container.setAttribute('data-token-type', 'reference'); - container.setAttribute('data-token-value', token.value); - - targetElement.appendChild(container); - reactContainers.add(container); - - ReactDOM.render(, container); - } - }); - - // Ensure cursor can be placed at end - if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { - targetElement.appendChild(document.createTextNode('')); - } -} - -/** - * Extracts tokens array from contentEditable DOM. - * Converts text nodes to TextInputToken and Token components to ReferenceInputToken. - */ -function domToTokenArray(element: HTMLElement): PromptInputProps.InputToken[] { - const tokens: PromptInputProps.InputToken[] = []; - let currentText = ''; - - const flushText = () => { - if (currentText) { - tokens.push({ type: 'text', text: currentText }); - currentText = ''; - } - }; - - const processNode = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - currentText += node.textContent || ''; - } else if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as HTMLElement; - const tokenId = element.getAttribute('data-token-id'); - const tokenType = element.getAttribute('data-token-type'); - - if (tokenType === 'reference' && tokenId) { - flushText(); - const label = element.textContent || ''; - const value = element.getAttribute('data-token-value') || label; - tokens.push({ - type: 'reference', - id: tokenId, - label, - value, - }); - } else { - // Process children for other elements - Array.from(node.childNodes).forEach(processNode); - } - } - }; - - Array.from(element.childNodes).forEach(processNode); - flushText(); - - return tokens; -} - interface InternalPromptInputProps extends SomeRequired, InternalBaseComponentProps {} @@ -146,59 +62,54 @@ const InternalPromptInput = React.forwardRef( disableSecondaryContentPaddings, nativeTextareaAttributes, style, - // Shortcuts tokens, + tokensToText, + mode, + onModeRemoved, menus, - activeMenuId, + onMenuSelect, + onMenuLoadItems, + menuErrorIconAriaLabel, __internalRootRef, ...rest }: InternalPromptInputProps, ref: Ref ) => { const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = useFormFieldContext(rest); - const baseProps = getBaseProps(rest); + // Refs const textareaRef = useRef(null); const editableElementRef = useRef(null); - const reactContainersRef = useRef>(new Set()); const isRenderingRef = useRef(false); const lastTokensRef = useRef(tokens); const savedSelectionRef = useRef(null); + const lastModeRef = useRef(mode); + // Mode detection const isRefresh = useVisualRefresh(); - const isTextAreaCompactMode = useDensityMode(textareaRef) === 'compact'; - const isEditableElementCompactMode = useDensityMode(editableElementRef) === 'compact'; - const isCompactMode = isTextAreaCompactMode || isEditableElementCompactMode; - const isMenusEnabled = tokens || menus; + useDensityMode(textareaRef); + useDensityMode(editableElementRef); + const isTokenMode = !!tokens; + // Style constants const PADDING = isRefresh ? designTokens.spaceXxs : designTokens.spaceXxxs; const LINE_HEIGHT = designTokens.lineHeightBodyM; - const DEFAULT_MAX_ROWS = 3; - - useEffect(() => { - console.log('Active menu is:', activeMenuId); - }, [activeMenuId]); useImperativeHandle( ref, () => ({ focus(...args: Parameters) { - if (isMenusEnabled) { + if (isTokenMode) { editableElementRef.current?.focus(...args); - // Restore saved selection if available - if (savedSelectionRef.current) { - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(savedSelectionRef.current.cloneRange()); - } + restoreSelection(savedSelectionRef.current); } else { textareaRef.current?.focus(...args); } }, select() { - if (isMenusEnabled) { + if (isTokenMode) { const selection = window.getSelection(); const range = document.createRange(); if (editableElementRef.current) { @@ -211,52 +122,15 @@ const InternalPromptInput = React.forwardRef( } }, setSelectionRange(...args: Parameters) { - if (isMenusEnabled) { - if (!editableElementRef.current) { - return; - } - + if (isTokenMode && editableElementRef.current) { const [start, end] = args; const selection = window.getSelection(); if (!selection) { return; } - // Helper to find node and offset for a given character position - // Reference tokens count as 1 character each - const findNodeAndOffset = (targetOffset: number): { node: Node; offset: number } | null => { - let currentOffset = 0; - - for (const child of Array.from(editableElementRef.current!.childNodes)) { - // Reference token element - counts as 1 character - if ( - child.nodeType === Node.ELEMENT_NODE && - (child as HTMLElement).getAttribute('data-token-type') === 'reference' - ) { - if (currentOffset === targetOffset) { - return { node: child, offset: 0 }; - } - currentOffset += 1; - continue; - } - - // Text node - if (child.nodeType === Node.TEXT_NODE) { - const textLength = child.textContent?.length || 0; - if (currentOffset + textLength >= targetOffset) { - return { node: child, offset: targetOffset - currentOffset }; - } - currentOffset += textLength; - } - } - - // Return last position if not found - const lastChild = editableElementRef.current!.lastChild; - return lastChild ? { node: lastChild, offset: lastChild.textContent?.length || 0 } : null; - }; - - const startPos = findNodeAndOffset(start ?? 0); - const endPos = findNodeAndOffset(end ?? 0); + const startPos = findNodeAndOffset(editableElementRef.current, start ?? 0); + const endPos = findNodeAndOffset(editableElementRef.current, end ?? 0); if (startPos && endPos) { const range = document.createRange(); @@ -270,9 +144,40 @@ const InternalPromptInput = React.forwardRef( } }, }), - [isMenusEnabled, textareaRef, editableElementRef] + [isTokenMode] ); + /** + * Dynamically adjusts the input height based on content and row constraints. + */ + const adjustInputHeight = useCallback(() => { + const element = isTokenMode ? editableElementRef.current : textareaRef.current; + if (!element) { + return; + } + + // Preserve scroll position for token mode + const scrollTop = element.scrollTop; + element.style.height = 'auto'; + + const minRowsHeight = isTokenMode + ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})` + : `calc(${LINE_HEIGHT} + ${designTokens.spaceScaledXxs} * 2)`; + const scrollHeight = `calc(${element.scrollHeight}px)`; + + if (maxRows === -1) { + element.style.height = `max(${scrollHeight}, ${minRowsHeight})`; + } else { + const effectiveMaxRows = maxRows <= 0 ? 3 : maxRows; + const maxRowsHeight = `calc(${effectiveMaxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; + element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; + } + + if (isTokenMode) { + element.scrollTop = scrollTop; + } + }, [isTokenMode, minRows, maxRows, LINE_HEIGHT, PADDING]); + const handleTextareaKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); @@ -281,10 +186,20 @@ const InternalPromptInput = React.forwardRef( event.currentTarget.form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value, tokens: [...(tokens ?? [])] }); + const plainText = isTokenMode + ? tokensToText + ? tokensToText(tokens ?? []) + : getPromptText(tokens ?? []) + : value; + fireNonCancelableEvent(onAction, { value: plainText, tokens: [...(tokens ?? [])] }); } }; + const handleTextareaChange = (event: React.ChangeEvent) => { + fireNonCancelableEvent(onChange, { value: event.target.value, tokens: [...(tokens ?? [])] }); + adjustInputHeight(); + }; + const handleEditableElementKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); @@ -294,180 +209,215 @@ const InternalPromptInput = React.forwardRef( form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value, tokens: [...(tokens ?? [])] }); + const plainText = tokensToText ? tokensToText(tokens ?? []) : getPromptText(tokens ?? []); + fireNonCancelableEvent(onAction, { value: plainText, tokens: [...(tokens ?? [])] }); } - // Detect space key for @mention and /command conversion (prototype feature) - if (event.key === ' ' && editableElementRef.current) { + // Single-backspace deletion for tokens + if (event.key === 'Backspace') { const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - const textNode = range.startContainer; - - if (textNode.nodeType === Node.TEXT_NODE && textNode.textContent) { - const textBeforeCursor = textNode.textContent.substring(0, range.startOffset); - - // Check for @mention pattern (can appear anywhere after whitespace) - const mentionMatch = textBeforeCursor.match(/(^|\s)@(\w+)$/); - // Check for /command pattern (only at the very start) - const commandMatch = textBeforeCursor.match(/^\/(\w+)$/); - - const match = commandMatch || mentionMatch; - const isCommand = !!commandMatch; - - if (match) { - event.preventDefault(); - - const matchText = match[2] || match[1]; // command uses [1], mention uses [2] - const beforeMatch = isCommand ? '' : textBeforeCursor.substring(0, match.index! + match[1].length); - const afterCursor = textNode.textContent.substring(range.startOffset); - - // Create reference token - const tokenId = isCommand ? `command:${matchText}` : `file:${matchText}`; - const tokenValue = isCommand - ? `${matchText}` - : `${matchText}`; - - const parent = textNode.parentNode as HTMLElement; - const beforeText = document.createTextNode(beforeMatch); - const spaceText = document.createTextNode(' '); - const afterText = document.createTextNode(afterCursor); - - if (beforeMatch) { - parent.insertBefore(beforeText, textNode); - } - - // Render reference token - const container = document.createElement('span'); - container.style.display = 'inline'; - container.contentEditable = 'false'; - container.setAttribute('data-token-id', tokenId); - container.setAttribute('data-token-type', 'reference'); - container.setAttribute('data-token-value', tokenValue); - - parent.insertBefore(container, textNode); - reactContainersRef.current.add(container); - ReactDOM.render(, container); - - parent.insertBefore(spaceText, textNode); - parent.insertBefore(afterText, textNode); - parent.removeChild(textNode); - - // Place cursor after the space - const newRange = document.createRange(); - newRange.setStart(spaceText, 1); - newRange.collapse(true); - selection.removeAllRanges(); - selection.addRange(newRange); + if (!selection?.rangeCount || !selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + let nodeToCheck = range.startContainer; + + // If at start of text node, check previous sibling + if (nodeToCheck.nodeType === Node.TEXT_NODE && range.startOffset === 0) { + nodeToCheck = nodeToCheck.previousSibling as Node; + } + // If cursor is in the contentEditable itself (not in a text node), check last child + else if (nodeToCheck === editableElementRef.current && range.startOffset > 0) { + nodeToCheck = editableElementRef.current.childNodes[range.startOffset - 1]; + } - // Trigger change event - const changeEvent = new Event('input', { bubbles: true }); - editableElementRef.current.dispatchEvent(changeEvent); + // If we're about to delete an empty text node, check if there's a token before it + if (nodeToCheck?.nodeType === Node.TEXT_NODE && nodeToCheck.textContent === '') { + const previousNode = nodeToCheck.previousSibling; + if (previousNode?.nodeType === Node.ELEMENT_NODE) { + const element = previousNode as Element; + if (element.hasAttribute('data-token-type')) { + // Skip the empty text node and target the token instead + nodeToCheck = previousNode; } } } - } - }; - const handleChangeTextArea = (event: React.ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: event.target.value, tokens: [...(tokens ?? [])] }); - adjustInputHeight(); - }; + // Check if it's a token element + if (nodeToCheck?.nodeType === Node.ELEMENT_NODE) { + const element = nodeToCheck as Element; + const tokenType = element.getAttribute('data-token-type'); - const handleChangeEditableElement: React.FormEventHandler = () => { - if (isRenderingRef.current) { - return; // Skip onChange during programmatic rendering + if (tokenType === 'mode') { + // For mode tokens, fire the callback and let parent handle state + event.preventDefault(); + if (onModeRemoved) { + fireNonCancelableEvent(onModeRemoved, { mode: undefined }); + } + } else if (tokenType) { + // For other tokens (like references), remove directly + event.preventDefault(); + element.remove(); + editableElementRef.current?.dispatchEvent(new Event('input', { bubbles: true })); + } + } } + }; - if (!editableElementRef.current) { + const handleEditableElementChange = useCallback>(() => { + if (isRenderingRef.current || !editableElementRef.current) { return; } - // Extract tokens from DOM - const extractedTokens = domToTokenArray(editableElementRef.current); - const plainText = getPromptText(extractedTokens); - - // Update ref to track that this change came from user input - lastTokensRef.current = extractedTokens; + let extractedTokens = domToTokenArray(editableElementRef.current); + + // TEMPORARY: Convert /word to mode tokens and @word to reference tokens + // This is prototype functionality and should be replaced with proper menu system + const processedTokens: PromptInputProps.InputToken[] = []; + let detectedMode: PromptInputProps.InputToken | undefined; + + extractedTokens.forEach(token => { + if (token.type === 'text' && token.value) { + const text = token.value; + // Match /word or @word followed by space + const pattern = /(\/\w+\s|@\w+\s)/g; + let lastIndex = 0; + let match; + let textBuffer = ''; + + while ((match = pattern.exec(text)) !== null) { + // Add any text before the match to buffer + textBuffer += text.substring(lastIndex, match.index); + + // Flush text buffer if not empty + if (textBuffer) { + processedTokens.push({ type: 'text', value: textBuffer }); + textBuffer = ''; + } - fireNonCancelableEvent(onChange, { value: plainText, tokens: extractedTokens }); - adjustInputHeight(); - }; + const matchedText = match[0]; + const trigger = matchedText[0]; // "/" or "@" + const word = matchedText.substring(1).trim(); // Remove trigger and trailing space + + if (trigger === '/' && !mode) { + // Only convert /word to mode token if no mode is already set + detectedMode = { + type: 'mode', + id: word, + label: word, + value: `Some dummy mode system prompt`, + }; + } else if (trigger === '/' && mode) { + // If mode already exists, keep /word as text + textBuffer += matchedText; + } else if (trigger === '@') { + // Convert @word to reference token + processedTokens.push({ + type: 'reference', + id: word, + label: word, + value: `Some dummy details`, + }); + } - const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; + lastIndex = pattern.lastIndex; + } - const adjustInputHeight = useCallback(() => { - if (editableElementRef.current || textareaRef.current) { - const element = isMenusEnabled ? editableElementRef.current : textareaRef.current; - // Save scroll position before adjusting height - const scrollTop = element!.scrollTop; + // Add any remaining text + textBuffer += text.substring(lastIndex); + if (textBuffer) { + processedTokens.push({ type: 'text', value: textBuffer }); + } + } else { + processedTokens.push(token); + } + }); - // this is required so the scrollHeight becomes dynamic, otherwise it will be locked at the highest value for the size it reached e.g. 500px - element!.style.height = 'auto'; + extractedTokens = processedTokens; - const minRowsHeight = isMenusEnabled - ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})` - : `calc(${LINE_HEIGHT} + ${designTokens.spaceScaledXxs} * 2)`; - const scrollHeight = `calc(${element!.scrollHeight}px)`; + const plainText = tokensToText ? tokensToText(extractedTokens) : getPromptText(extractedTokens); - if (maxRows === -1) { - element!.style.height = `max(${scrollHeight}, ${minRowsHeight})`; - } else { - const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; - element!.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; + // Check if mode token was removed from the DOM + if (lastModeRef.current && onModeRemoved) { + const hasModeToken = editableElementRef.current.querySelector('[data-token-type="mode"]'); + if (!hasModeToken) { + // Mode token was removed, notify parent + fireNonCancelableEvent(onModeRemoved, { mode: undefined }); + lastModeRef.current = undefined; } + } - if (isMenusEnabled) { - // Restore scroll position after adjusting height - element!.scrollTop = scrollTop; - } + // If we detected new tokens (mode or reference), re-render them as Token elements + const hasNewTokens = + detectedMode || + extractedTokens.some( + (token, index) => + token.type === 'reference' && + (!lastTokensRef.current || + !lastTokensRef.current[index] || + lastTokensRef.current[index].type !== 'reference') + ); + + if (hasNewTokens && editableElementRef.current) { + isRenderingRef.current = true; + const allTokens = detectedMode ? [detectedMode, ...extractedTokens] : extractedTokens; + renderTokensToDOM(allTokens, editableElementRef.current, reactContainersRef.current); + isRenderingRef.current = false; + // Place cursor at the end after rendering tokens + setCursorToEnd(editableElementRef.current); } - }, [isMenusEnabled, minRows, LINE_HEIGHT, PADDING, maxRows]); - useEffect(() => { - const handleResize = () => { - adjustInputHeight(); - }; + // Filter out mode tokens from extractedTokens - mode is tracked separately + const tokensWithoutMode = extractedTokens.filter(token => token.type !== 'mode'); - window.addEventListener('resize', handleResize); + lastTokensRef.current = tokensWithoutMode; + fireNonCancelableEvent(onChange, { value: plainText, tokens: tokensWithoutMode, mode: detectedMode }); + adjustInputHeight(); + }, [tokensToText, onModeRemoved, onChange, adjustInputHeight, mode]); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [adjustInputHeight]); + const handleEditableElementBlur = useCallback(() => { + if (isTokenMode) { + savedSelectionRef.current = saveSelection(); + } + if (onBlur) { + fireNonCancelableEvent(onBlur); + } + }, [isTokenMode, onBlur]); - // Render tokens into contentEditable when they change externally (not from user typing) + // Render tokens into contentEditable when they change externally useEffect(() => { - if (isMenusEnabled && editableElementRef.current && tokens) { - // Only re-render if tokens changed from outside (not from user input) - if (tokens !== lastTokensRef.current) { + if (isTokenMode && editableElementRef.current) { + // Only re-render if tokens or mode changed from outside (not from user input) + if (tokens !== lastTokensRef.current || mode !== lastModeRef.current) { isRenderingRef.current = true; - renderTokensToDOM(tokens, editableElementRef.current, reactContainersRef.current); + // Combine mode token (if exists) with regular tokens + const allTokens = mode ? [mode, ...(tokens ?? [])] : (tokens ?? []); + renderTokensToDOM(allTokens, editableElementRef.current, reactContainersRef.current); isRenderingRef.current = false; lastTokensRef.current = tokens; - - // Position cursor at the end of the content when tokens are set externally - requestAnimationFrame(() => { - if (!editableElementRef.current) { - return; - } - - // Position cursor at the end of the editable element - const range = document.createRange(); - const selection = window.getSelection(); - range.selectNodeContents(editableElementRef.current); - range.collapse(false); // false = collapse to end - selection?.removeAllRanges(); - selection?.addRange(range); - }); + lastModeRef.current = mode; + setCursorToEnd(editableElementRef.current); } } - adjustInputHeight(); - }, [isMenusEnabled, tokens, value, adjustInputHeight, maxRows, isCompactMode]); + }, [isTokenMode, tokens, mode, adjustInputHeight]); + + // Handle window resize + useEffect(() => { + const handleResize = () => adjustInputHeight(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [adjustInputHeight]); + + // Track mode changes + useEffect(() => { + lastModeRef.current = mode; + }, [mode]); + // Auto-focus on mount useEffect(() => { - if (isMenusEnabled && autoFocus && editableElementRef.current) { + if (isTokenMode && autoFocus && editableElementRef.current) { editableElementRef.current.focus(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -477,13 +427,34 @@ const InternalPromptInput = React.forwardRef( useEffect(() => { const containers = reactContainersRef.current; return () => { - containers.forEach(container => { - ReactDOM.unmountComponentAtNode(container); - }); + containers.forEach(container => ReactDOM.unmountComponentAtNode(container)); containers.clear(); }; }, []); + // Future: Implement menu trigger detection and dropdown rendering + // When menus are defined, detect trigger characters and show appropriate menu + // Use onMenuSelect to handle option selection + // Use onMenuLoadItems for async menu data loading + // Use menuErrorIconAriaLabel for accessibility in error states + void menus; + void onMenuSelect; + void onMenuLoadItems; + void menuErrorIconAriaLabel; + + const hasActionButton = !!( + actionButtonIconName || + actionButtonIconSvg || + actionButtonIconUrl || + customPrimaryAction + ); + + const showPlaceholder = + isTokenMode && + placeholder && + !mode && + (!tokens || tokens.length === 0 || (tokens.length === 1 && tokens[0].type === 'text' && !tokens[0].value)); + const textareaAttributes: React.TextareaHTMLAttributes = { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, @@ -497,25 +468,20 @@ const InternalPromptInput = React.forwardRef( [styles.warning]: warning, }), autoComplete: convertAutoComplete(autoComplete), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, spellCheck: spellcheck, disabled, readOnly: readOnly ? true : undefined, rows: minRows, + value: value || '', onKeyDown: handleTextareaKeyDown, onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - // We set a default value on the component in order to force it into the controlled mode. - value: value || '', - onChange: handleChangeTextArea, + onChange: handleTextareaChange, onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; - // Determine if placeholder should be visible - const showEditableElementPlaceholder = - isMenusEnabled && - placeholder && - (!tokens || tokens.length === 0 || (tokens.length === 1 && tokens[0].type === 'text' && !tokens[0].text)); - const editableElementAttributes: React.HTMLAttributes & { 'data-placeholder'?: string; } = { @@ -531,35 +497,19 @@ const InternalPromptInput = React.forwardRef( [styles.invalid]: invalid, [styles.warning]: warning, [styles['textarea-disabled']]: disabled, - [styles['placeholder-visible']]: showEditableElementPlaceholder, + [styles['placeholder-visible']]: showPlaceholder, }), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, spellCheck: spellcheck, onKeyDown: handleEditableElementKeyDown, onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - onInput: handleChangeEditableElement, - onBlur: () => { - // Save selection before blur for contentEditable - if (isMenusEnabled) { - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - savedSelectionRef.current = selection.getRangeAt(0); - } - } - if (onBlur) { - fireNonCancelableEvent(onBlur); - } - }, + onInput: handleEditableElementChange, + onBlur: handleEditableElementBlur, onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; - if (disableBrowserAutocorrect) { - textareaAttributes.autoCorrect = 'off'; - textareaAttributes.autoCapitalize = 'off'; - editableElementAttributes.autoCorrect = 'off'; - editableElementAttributes.autoCapitalize = 'off'; - } - - const action = ( + const actionButton = (
{customPrimaryAction ?? ( fireNonCancelableEvent(onAction, { value, tokens: [...(tokens ?? [])] })} + onClick={() => { + const plainText = isTokenMode + ? tokensToText + ? tokensToText(tokens ?? []) + : getPromptText(tokens ?? []) + : value; + fireNonCancelableEvent(onAction, { value: plainText, tokens: [...(tokens ?? [])] }); + }} variant="icon" /> )} @@ -603,14 +560,21 @@ const InternalPromptInput = React.forwardRef( {secondaryContent}
)} +
- {tokens ? ( + {isTokenMode ? ( <> - {name && } + {name && ( + + )}
)} - {hasActionButton && !secondaryActions && action} + {hasActionButton && !secondaryActions && actionButton}
+ {secondaryActions && (
(isMenusEnabled ? editableElementRef.current?.focus() : textareaRef.current?.focus())} + onClick={() => (isTokenMode ? editableElementRef.current?.focus() : textareaRef.current?.focus())} /> - {hasActionButton && action} + {hasActionButton && actionButton}
)}
diff --git a/src/prompt-input/selection-utils.ts b/src/prompt-input/selection-utils.ts new file mode 100644 index 0000000000..050f1bc150 --- /dev/null +++ b/src/prompt-input/selection-utils.ts @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Finds the DOM node and offset for a given character position in contentEditable. + * Reference tokens count as 1 character each. + */ +export function findNodeAndOffset(element: HTMLElement, targetOffset: number): { node: Node; offset: number } | null { + let currentOffset = 0; + + for (const child of Array.from(element.childNodes)) { + // Reference token element - counts as 1 character + if ( + child.nodeType === Node.ELEMENT_NODE && + (child as HTMLElement).getAttribute('data-token-type') === 'reference' + ) { + if (currentOffset === targetOffset) { + return { node: child, offset: 0 }; + } + currentOffset += 1; + continue; + } + + // Text node + if (child.nodeType === Node.TEXT_NODE) { + const textLength = child.textContent?.length || 0; + if (currentOffset + textLength >= targetOffset) { + return { node: child, offset: targetOffset - currentOffset }; + } + currentOffset += textLength; + } + } + + // Return last position if not found + const lastChild = element.lastChild; + return lastChild ? { node: lastChild, offset: lastChild.textContent?.length || 0 } : null; +} + +/** + * Sets the cursor position at the end of contentEditable element. + */ +export function setCursorToEnd(element: HTMLElement): void { + requestAnimationFrame(() => { + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(element); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + +/** + * Saves the current selection range. + */ +export function saveSelection(): Range | null { + const selection = window.getSelection(); + return selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; +} + +/** + * Restores a previously saved selection range. + */ +export function restoreSelection(range: Range | null): void { + if (range) { + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range.cloneRange()); + } +} diff --git a/src/prompt-input/token-utils.tsx b/src/prompt-input/token-utils.tsx new file mode 100644 index 0000000000..048ed32c11 --- /dev/null +++ b/src/prompt-input/token-utils.tsx @@ -0,0 +1,238 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Token from '../token/internal'; +import { PromptInputProps } from './interfaces'; + +const TOKEN_DATA_PREFIX = 'data-token-'; +const TOKEN_TYPE_ATTRIBUTE = `${TOKEN_DATA_PREFIX}type`; + +/** + * Creates a DOM element for a token with data attributes. + * @param type - The token type identifier + * @param attributes - Key-value pairs to be set as data-token-* attributes + * @returns A configured span element ready for token rendering + */ +function createTokenContainerElement(type: string, attributes: Record): HTMLElement { + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + container.setAttribute(TOKEN_TYPE_ATTRIBUTE, type); + + Object.entries(attributes).forEach(([key, value]) => { + container.setAttribute(`${TOKEN_DATA_PREFIX}${key}`, value); + }); + + return container; +} + +/** + * Token renderer factory for different token types. + */ +const tokenRenderers: Record< + PromptInputProps.InputToken['type'], + (token: PromptInputProps.InputToken, target: HTMLElement, containers: Set) => void +> = { + text: (token, target) => { + if (token.type === 'text' && token.value) { + target.appendChild(document.createTextNode(token.value)); + } + }, + reference: (token, target, containers) => { + if (token.type === 'reference') { + const container = createTokenContainerElement('reference', { + id: token.id || '', + value: token.value || '', + }); + target.appendChild(container); + containers.add(container); + ReactDOM.render(, container); + } + }, + mode: (token, target, containers) => { + if (token.type === 'mode') { + const container = createTokenContainerElement('mode', { + id: token.id || '', + value: token.value || '', + }); + target.appendChild(container); + containers.add(container); + ReactDOM.render(, container); + } + }, +}; + +/** + * Cleans up React components and DOM content from the target element. + * @param targetElement - The element to clean + * @param reactContainers - Set of React container elements to unmount + */ +function cleanupDOM(targetElement: HTMLElement, reactContainers: Set): void { + reactContainers.forEach(container => { + try { + ReactDOM.unmountComponentAtNode(container); + } catch (error) { + console.warn('Failed to unmount React component:', error); + } + }); + reactContainers.clear(); + targetElement.innerHTML = ''; +} + +/** + * Ensures the contentEditable element can receive cursor at the end. + * Adds an empty text node if the last child is an element node. + * @param targetElement - The element to ensure cursor placement + */ +function ensureCursorPlacement(targetElement: HTMLElement): void { + if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { + targetElement.appendChild(document.createTextNode('')); + } +} + +/** + * Renders an array of tokens into a contentEditable element. + * Handles both text tokens (as text nodes) and reference tokens (as React components). + * @param tokens - Array of tokens to render + * @param targetElement - The contentEditable element to render into + * @param reactContainers - Set to track React container elements for cleanup + * @throws {Error} If targetElement is not a valid HTMLElement + */ +export function renderTokensToDOM( + tokens: readonly PromptInputProps.InputToken[], + targetElement: HTMLElement, + reactContainers: Set +): void { + if (!targetElement || !(targetElement instanceof HTMLElement)) { + throw new Error('Invalid target element provided to renderTokensToDOM'); + } + + cleanupDOM(targetElement, reactContainers); + + tokens.forEach(token => { + const renderer = tokenRenderers[token.type]; + if (renderer) { + renderer(token, targetElement, reactContainers); + } else { + console.warn(`Unknown token type: ${token.type}`); + } + }); + + ensureCursorPlacement(targetElement); +} + +/** + * Extracts all data-token-* attributes from an element. + * @param element - The element to extract attributes from + * @returns Object mapping attribute keys to values (without the data-token- prefix) + */ +function extractTokenData(element: HTMLElement): Record { + return Array.from(element.attributes) + .filter(attr => attr.name.startsWith(TOKEN_DATA_PREFIX)) + .reduce( + (acc, attr) => { + const key = attr.name.replace(TOKEN_DATA_PREFIX, ''); + acc[key] = attr.value; + return acc; + }, + {} as Record + ); +} + +/** + * Token extractor factory for different token types. + */ +const tokenExtractors: Record< + string, + (element: HTMLElement, flushText: () => void) => PromptInputProps.InputToken | null +> = { + reference: (element, flushText) => { + flushText(); + const data = extractTokenData(element); + return { + type: 'reference', + id: data.id || '', + label: element.textContent || '', + value: data.value || element.textContent || '', + }; + }, + mode: (element, flushText) => { + flushText(); + const data = extractTokenData(element); + return { + type: 'mode', + id: data.id || '', + label: element.textContent || '', + value: data.value || '', + }; + }, +}; + +/** + * Extracts an array of tokens from a contentEditable DOM element. + * Converts text nodes to TextInputToken and token elements to their respective types. + * @param element - The contentEditable element to extract tokens from + * @returns Array of extracted tokens + * @throws {Error} If element is not a valid HTMLElement + */ +export function domToTokenArray(element: HTMLElement): PromptInputProps.InputToken[] { + if (!element || !(element instanceof HTMLElement)) { + throw new Error('Invalid element provided to domToTokenArray'); + } + + const tokens: PromptInputProps.InputToken[] = []; + let textBuffer = ''; + + const flushTextBuffer = (): void => { + if (textBuffer) { + tokens.push({ type: 'text', value: textBuffer }); + textBuffer = ''; + } + }; + + const processNode = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + textBuffer += node.textContent || ''; + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const tokenType = el.getAttribute(TOKEN_TYPE_ATTRIBUTE); + + if (tokenType && tokenExtractors[tokenType]) { + const token = tokenExtractors[tokenType](el, flushTextBuffer); + if (token) { + tokens.push(token); + } + } else { + // Recursively process children for non-token elements + Array.from(node.childNodes).forEach(processNode); + } + } + }; + + Array.from(element.childNodes).forEach(processNode); + flushTextBuffer(); + + return tokens; +} + +export function getPromptText(tokens: readonly PromptInputProps.InputToken[], labelsOnly = false): string { + if (tokens.length === 0) { + return ''; + } + + return tokens + .map(token => { + if (token.type === 'text') { + return token.value || ''; + } else if (token.type === 'reference') { + return labelsOnly ? token.label || '' : token.value || ''; + } else if (token.type === 'mode') { + // Mode tokens don't contribute to the text value + return ''; + } + return ''; + }) + .join(''); +} diff --git a/src/prompt-input/utils.tsx b/src/prompt-input/utils.tsx deleted file mode 100644 index b2db8bd864..0000000000 --- a/src/prompt-input/utils.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { PromptInputProps } from './interfaces'; - -/** - * Extracts text from a PromptInput token array. - * By default, returns full token values (for form submission). - * Set labelsOnly=true to get just the visible labels (for UI display/counting). - * - * @param tokens - The PromptInput token array - * @param labelsOnly - If true, returns only visible text; if false, returns full token values - * - * @example - * const tokens = [ - * { type: 'text', text: 'Hello ' }, - * { type: 'reference', id: 'file:user', label: 'user', value: 'user' } - * ]; - * getPromptText(tokens); // "Hello user" - * getPromptText(tokens, true); // "Hello user" - */ -export function getPromptText(tokens: readonly PromptInputProps.InputToken[], labelsOnly = false): string { - if (!tokens) { - return ''; - } - - return tokens - .map(token => { - if (token.type === 'text') { - return token.text; - } else if (token.type === 'reference') { - return labelsOnly ? token.label : token.value; - } - return ''; - }) - .join(''); -} - -export default getPromptText; From 32b1fedbdd44f0bd94a2687bc8417fecb3360af4 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Fri, 12 Dec 2025 22:14:47 +0100 Subject: [PATCH 6/6] Add menus and restructure contentEditable behaviours --- pages/prompt-input/shortcuts.page.tsx | 523 ++++++++ pages/prompt-input/simple.page.tsx | 404 ++---- .../__snapshots__/documenter.test.ts.snap | 889 ++++++++++++- .../dropdown/dropdown-fit-handler.ts | 36 +- src/internal/components/dropdown/index.tsx | 11 +- .../components/dropdown/interfaces.ts | 10 + src/prompt-input/index.tsx | 6 +- src/prompt-input/interfaces.ts | 201 ++- src/prompt-input/internal.tsx | 1123 +++++++++++++---- src/prompt-input/menus/menu-controller.ts | 151 +++ .../menus/menu-load-more-controller.ts | 59 + src/prompt-input/menus/menu-options-list.tsx | 84 ++ src/prompt-input/selection-utils.ts | 70 - src/prompt-input/styles.scss | 10 + src/prompt-input/test-classes/styles.scss | 4 + src/prompt-input/{ => tokens}/token-utils.tsx | 95 +- .../tokens/use-editable-tokens.ts | 222 ++++ src/prompt-input/utils/cursor-utils.ts | 119 ++ src/prompt-input/utils/keyboard-handlers.ts | 139 ++ src/test-utils/dom/prompt-input/index.ts | 170 ++- 20 files changed, 3532 insertions(+), 794 deletions(-) create mode 100644 pages/prompt-input/shortcuts.page.tsx create mode 100644 src/prompt-input/menus/menu-controller.ts create mode 100644 src/prompt-input/menus/menu-load-more-controller.ts create mode 100644 src/prompt-input/menus/menu-options-list.tsx delete mode 100644 src/prompt-input/selection-utils.ts rename src/prompt-input/{ => tokens}/token-utils.tsx (70%) create mode 100644 src/prompt-input/tokens/use-editable-tokens.ts create mode 100644 src/prompt-input/utils/cursor-utils.ts create mode 100644 src/prompt-input/utils/keyboard-handlers.ts diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx new file mode 100644 index 0000000000..a5ddf5ed9c --- /dev/null +++ b/pages/prompt-input/shortcuts.page.tsx @@ -0,0 +1,523 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useState } from 'react'; + +import { + AppLayout, + Box, + ButtonGroup, + ButtonGroupProps, + Checkbox, + ColumnLayout, + FileTokenGroup, + FormField, + PromptInput, + PromptInputProps, + SpaceBetween, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import labels from '../app-layout/utils/labels'; +import { i18nStrings } from '../file-upload/shared'; + +const MAX_CHARS = 2000; + +type DemoContext = React.Context< + AppContextType<{ + isDisabled: boolean; + isReadOnly: boolean; + isInvalid: boolean; + hasWarning: boolean; + hasText: boolean; + hasSecondaryContent: boolean; + hasSecondaryActions: boolean; + hasPrimaryActions: boolean; + hasInfiniteMaxRows: boolean; + disableActionButton: boolean; + disableBrowserAutocorrect: boolean; + enableSpellcheck: boolean; + hasName: boolean; + enableAutoFocus: boolean; + }> +>; + +const placeholderText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; + +// Sample data for menus +const mentionOptions = [ + { value: 'john', label: 'John Doe', description: 'Software Engineer' }, + { value: 'jane', label: 'Jane Smith', description: 'Product Manager' }, + { value: 'bob', label: 'Bob Johnson', description: 'Designer' }, + { value: 'alice', label: 'Alice Williams', description: 'Data Scientist' }, +]; + +const commandOptions = [ + { value: 'dev', label: 'Developer Mode', description: 'Optimized for code generation' }, + { value: 'creative', label: 'Creative Mode', description: 'Optimized for creative writing' }, + { value: 'analyze', label: 'Analyze Mode', description: 'Optimized for data analysis' }, + { value: 'summarize', label: 'Summarize Mode', description: 'Optimized for summarization' }, +]; + +const topicOptions = [ + { value: 'aws', label: 'AWS', description: 'Amazon Web Services' }, + { value: 'cloudscape', label: 'Cloudscape', description: 'Design system' }, + { value: 'react', label: 'React', description: 'JavaScript library' }, + { value: 'typescript', label: 'TypeScript', description: 'Typed JavaScript' }, + { value: 'accessibility', label: 'Accessibility', description: 'A11y best practices' }, + { value: 'performance', label: 'Performance', description: 'Optimization tips' }, +]; + +export default function PromptInputShortcutsPage() { + const [tokens, setTokens] = useState([]); + const [mode, setMode] = useState(); + const [plainTextValue, setPlainTextValue] = useState(''); + const [files, setFiles] = useState([]); + const [extractedText, setExtractedText] = useState(''); + const [selectionStart, setSelectionStart] = useState('0'); + const [selectionEnd, setSelectionEnd] = useState('0'); + + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + + const { + isDisabled, + isReadOnly, + isInvalid, + hasWarning, + hasText, + hasSecondaryActions, + hasSecondaryContent, + hasPrimaryActions, + hasInfiniteMaxRows, + disableActionButton, + disableBrowserAutocorrect, + enableSpellcheck, + hasName, + enableAutoFocus, + } = urlParams; + + const [items, setItems] = React.useState([ + { label: 'Item 1', dismissLabel: 'Remove item 1', disabled: isDisabled }, + { label: 'Item 2', dismissLabel: 'Remove item 2', disabled: isDisabled }, + { label: 'Item 3', dismissLabel: 'Remove item 3', disabled: isDisabled }, + ]); + + // Define menus for shortcuts + const menus = [ + { + id: 'mentions', + trigger: '@', + options: mentionOptions, + filteringType: 'auto' as const, + }, + { + id: 'mode', + trigger: '/', + options: commandOptions, + filteringType: 'auto' as const, + useAtStart: true, + }, + { + id: 'topics', + trigger: '#', + options: topicOptions, + filteringType: 'auto' as const, + }, + ]; + + useEffect(() => { + if (hasText) { + setTokens([{ type: 'text', value: placeholderText }]); + } + }, [hasText]); + + useEffect(() => { + if (plainTextValue !== placeholderText) { + setUrlParams({ hasText: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plainTextValue]); + + useEffect(() => { + if (items.length === 0) { + ref.current?.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + useEffect(() => { + const newItems = items.map(item => ({ + label: item.label, + dismissLabel: item.dismissLabel, + disabled: isDisabled, + })); + setItems([...newItems]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled]); + + const ref = React.createRef(); + + const buttonGroupRef = React.useRef(null); + + const onDismiss = (event: { detail: { fileIndex: number } }) => { + const newItems = [...files]; + newItems.splice(event.detail.fileIndex, 1); + setFiles(newItems); + }; + + return ( + +

PromptInput demo

+ + + setUrlParams({ isDisabled: !isDisabled })}> + Disabled + + setUrlParams({ isReadOnly: !isReadOnly })}> + Read-only + + setUrlParams({ isInvalid: !isInvalid })}> + Invalid + + setUrlParams({ hasWarning: !hasWarning })}> + Warning + + + setUrlParams({ + hasSecondaryContent: !hasSecondaryContent, + }) + } + > + Secondary content + + + setUrlParams({ + hasSecondaryActions: !hasSecondaryActions, + }) + } + > + Secondary actions + + + setUrlParams({ + hasPrimaryActions: !hasPrimaryActions, + }) + } + > + Custom primary actions + + + setUrlParams({ + hasInfiniteMaxRows: !hasInfiniteMaxRows, + }) + } + > + Infinite max rows + + + setUrlParams({ + disableActionButton: !disableActionButton, + }) + } + > + Disable action button + + + setUrlParams({ + disableBrowserAutocorrect: !disableBrowserAutocorrect, + }) + } + > + Disable browser autocorrect + + + setUrlParams({ + enableSpellcheck: !enableSpellcheck, + }) + } + > + Enable spellcheck + + + setUrlParams({ + hasName: !hasName, + }) + } + > + Has name attribute (for forms) + + + setUrlParams({ + enableAutoFocus: !enableAutoFocus, + }) + } + > + Enable auto focus + + + + + + + + + +
+ + + +
+ + {extractedText && ( + +
+ Last submitted text: + + {extractedText} + +
+
+ )} + + {tokens.length > 0 && ( + +
+ Current tokens: + + {JSON.stringify(tokens, null, 2)} + +
+
+ )} + + {mode && ( + +
+ Current mode: + + {JSON.stringify(mode, null, 2)} + +
+
+ )} + + { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + console.log('FORM SUBMITTED (fallback):', { + 'user-prompt': formData.get('user-prompt'), + }); + }} + > + + MAX_CHARS || isInvalid) && 'The query has too many characters.'} + warningText={hasWarning && 'This input has a warning'} + constraintText={ + <> + This service is subject to some policy. Character count: {plainTextValue.length}/{MAX_CHARS} + + } + i18nStrings={{ errorIconAriaLabel: 'Error' }} + > + { + setMode(undefined); + }} + onChange={event => { + setTokens(event.detail.tokens); + setPlainTextValue(event.detail.value ?? ''); + }} + onAction={({ detail }) => { + setExtractedText(detail.value ?? ''); + // Clear tokens after submission, but keep the mode + setTokens([]); + setPlainTextValue(''); + + window.alert( + `Submitted:\n\nPlain text: ${detail.value ?? ''}\n\nTokens: ${JSON.stringify( + detail.tokens, + null, + 2 + )}` + ); + }} + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || plainTextValue.length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + disableActionButton={disableActionButton} + disableBrowserAutocorrect={disableBrowserAutocorrect} + spellcheck={enableSpellcheck} + name={hasName ? 'user-prompt' : undefined} + autoFocus={enableAutoFocus} + menus={menus} + onMenuItemSelect={event => { + console.log('Menu selection:', event.detail); + + // Find the menu definition to check if it creates mode tokens + const selectedMenu = menus?.find(menu => menu.id === event.detail.menuId); + + // Handle mode selection based on useAtStart property + if (selectedMenu?.useAtStart) { + const newMode = { + id: event.detail.option.value ?? '', + label: event.detail.option.label ?? event.detail.option.value ?? '', + value: event.detail.option.value ?? '', + }; + setMode(newMode); + } + }} + i18nStrings={{ + selectedMenuItemAriaLabel: 'Selected', + menuErrorIconAriaLabel: 'Error', + menuRecoveryText: 'Retry', + }} + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + + detail.id.includes('files') && setFiles(detail.files)} + items={[ + { + type: 'icon-file-input', + id: 'files', + text: 'Upload files', + multiple: true, + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Go full page', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'remove', + iconName: 'remove', + text: 'Remove', + disabled: isDisabled || isReadOnly, + }, + ]} + variant="icon" + /> + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> + +
+ + + +
+ } + /> + ); +} diff --git a/pages/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx index 1e3a836171..0b42d43ce6 100644 --- a/pages/prompt-input/simple.page.tsx +++ b/pages/prompt-input/simple.page.tsx @@ -12,7 +12,6 @@ import { FileTokenGroup, FormField, PromptInput, - PromptInputProps, SpaceBetween, SplitPanel, } from '~components'; @@ -34,12 +33,6 @@ type DemoContext = React.Context< hasSecondaryActions: boolean; hasPrimaryActions: boolean; hasInfiniteMaxRows: boolean; - disableActionButton: boolean; - disableBrowserAutocorrect: boolean; - enableSpellcheck: boolean; - hasName: boolean; - enableAutoFocus: boolean; - enableReferences: boolean; }> >; @@ -47,16 +40,9 @@ const placeholderText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; export default function PromptInputPage() { - const [textareaValue, setTextareaValue] = useState(''); - const [tokens, setTokens] = useState([]); - const [mode, setMode] = useState(); - const [plainTextValue, setPlainTextValue] = useState(''); - const [valueInSplitPanel, setValueInSplitPanel] = useState(''); + const [textareaValue, setTextareaValue] = useState(''); + const [valueInSplitPanel, setValueInSplitPanel] = useState(''); const [files, setFiles] = useState([]); - const [extractedText, setExtractedText] = useState(''); - const [selectionStart, setSelectionStart] = useState('0'); - const [selectionEnd, setSelectionEnd] = useState('0'); - const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); const { @@ -69,12 +55,6 @@ export default function PromptInputPage() { hasSecondaryContent, hasPrimaryActions, hasInfiniteMaxRows, - disableActionButton, - disableBrowserAutocorrect, - enableSpellcheck, - hasName, - enableAutoFocus, - enableReferences, } = urlParams; const [items, setItems] = React.useState([ @@ -85,21 +65,16 @@ export default function PromptInputPage() { useEffect(() => { if (hasText) { - if (enableReferences) { - setTokens([{ type: 'text', value: placeholderText }]); - } else { - setTextareaValue(placeholderText); - } + setTextareaValue(placeholderText); } - }, [hasText, enableReferences]); + }, [hasText]); useEffect(() => { - const currentValue = enableReferences ? plainTextValue : textareaValue; - if (currentValue !== placeholderText) { + if (textareaValue !== placeholderText) { setUrlParams({ hasText: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [textareaValue, plainTextValue, enableReferences]); + }, [textareaValue]); useEffect(() => { if (items.length === 0) { @@ -118,7 +93,7 @@ export default function PromptInputPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDisabled]); - const ref = React.createRef(); + const ref = React.createRef(); const buttonGroupRef = React.useRef(null); @@ -188,68 +163,6 @@ export default function PromptInputPage() { > Infinite max rows - - setUrlParams({ - disableActionButton: !disableActionButton, - }) - } - > - Disable action button - - - setUrlParams({ - disableBrowserAutocorrect: !disableBrowserAutocorrect, - }) - } - > - Disable browser autocorrect - - - setUrlParams({ - enableSpellcheck: !enableSpellcheck, - }) - } - > - Enable spellcheck - - - setUrlParams({ - hasName: !hasName, - }) - } - > - Has name attribute (for forms) - - - setUrlParams({ - enableAutoFocus: !enableAutoFocus, - }) - } - > - Enable auto focus - - { - setUrlParams({ enableReferences: !enableReferences }); - // Reset values when switching modes - setTextareaValue(''); - setTokens([]); - setPlainTextValue(''); - }} - > - Enable references (tokens mode - supports @mentions and /commands) - -
- - - -
- - {extractedText && ( - -
- Last submitted text: - - {extractedText} - -
-
- )} - - {enableReferences && tokens.length > 0 && ( - -
- Current tokens: - - {JSON.stringify(tokens, null, 2)} - -
-
- )} - - {enableReferences && mode && ( - -
- Current mode: - - {JSON.stringify(mode, null, 2)} - -
-
- )} - -
{ - event.preventDefault(); - const formData = new FormData(event.currentTarget); - console.log('FORM SUBMITTED (fallback):', { - 'user-prompt': formData.get('user-prompt'), - }); - }} - > - - MAX_CHARS || isInvalid) && - 'The query has too many characters.' - } - warningText={hasWarning && 'This input has a warning'} - constraintText={ - <> - This service is subject to some policy. Character count:{' '} - {enableReferences ? plainTextValue.length : textareaValue.length}/{MAX_CHARS} - {enableReferences && ' (Token mode: type @word or /command and press space)'} - + setTextareaValue(event.detail.value)} + onAction={event => window.alert(`Submitted the following: ${event.detail.value}`)} + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || textareaValue.length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined } - label={User prompt {enableReferences && '(with references)'}} - i18nStrings={{ errorIconAriaLabel: 'Error' }} - > - { - setMode(undefined); - }} - onChange={(event: any) => { - if (enableReferences) { - setTokens(event.detail.tokens); - setPlainTextValue(event.detail.value ?? ''); - - // Update mode if it changed - if (event.detail.mode !== undefined) { - setMode(event.detail.mode); - } - } else { - setTextareaValue(event.detail.value ?? ''); - } - }} - onAction={({ detail }) => { - setExtractedText(detail.value ?? ''); - - if (enableReferences) { - // Clear tokens after submission, but keep the mode - setTokens([]); - setPlainTextValue(''); - } else { - setTextareaValue(''); - } - - window.alert( - `Submitted:\n\nPlain text: ${detail.value ?? ''}\n\n${ - enableReferences ? `Tokens: ${JSON.stringify(detail.tokens, null, 2)}` : '' - }` - ); - }} - placeholder="Ask a question" - maxRows={hasInfiniteMaxRows ? -1 : 4} - disabled={isDisabled} - readOnly={isReadOnly} - invalid={ - isInvalid || - (enableReferences ? plainTextValue.length > MAX_CHARS : textareaValue.length > MAX_CHARS) - } - warning={hasWarning} - ref={ref} - disableSecondaryActionsPaddings={true} - disableActionButton={disableActionButton} - disableBrowserAutocorrect={disableBrowserAutocorrect} - spellcheck={enableSpellcheck} - name={hasName ? 'user-prompt' : undefined} - autoFocus={enableAutoFocus} - customPrimaryAction={ - hasPrimaryActions ? ( + secondaryActions={ + hasSecondaryActions ? ( + detail.id.includes('files') && setFiles(detail.files)} items={[ + { + type: 'icon-file-input', + id: 'files', + text: 'Upload files', + multiple: true, + }, { type: 'icon-button', - id: 'record', - text: 'Record', - iconName: 'microphone', + id: 'expand', + iconName: 'expand', + text: 'Go full page', disabled: isDisabled || isReadOnly, }, { type: 'icon-button', - id: 'submit', - text: 'Submit', - iconName: 'send', + id: 'remove', + iconName: 'remove', + text: 'Remove', disabled: isDisabled || isReadOnly, }, ]} + variant="icon" /> - ) : undefined - } - secondaryActions={ - hasSecondaryActions ? ( - - detail.id.includes('files') && setFiles(detail.files)} - items={[ - { - type: 'icon-file-input', - id: 'files', - text: 'Upload files', - multiple: true, - }, - { - type: 'icon-button', - id: 'expand', - iconName: 'expand', - text: 'Go full page', - disabled: isDisabled || isReadOnly, - }, - { - type: 'icon-button', - id: 'remove', - iconName: 'remove', - text: 'Remove', - disabled: isDisabled || isReadOnly, - }, - ]} - variant="icon" - /> - - ) : undefined - } - secondaryContent={ - hasSecondaryContent && files.length > 0 ? ( - ({ - file, - }))} - showFileThumbnail={true} - onDismiss={onDismiss} - i18nStrings={i18nStrings} - alignment="horizontal" - /> - ) : undefined - } - /> - -
- - + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> + +
+
} @@ -512,7 +300,7 @@ export default function PromptInputPage() { setValueInSplitPanel(event.detail.value ?? '')} + onChange={event => setValueInSplitPanel(event.detail.value)} /> } diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 8b1f56e536..aab626316b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -18840,10 +18840,17 @@ exports[`Components definition for prompt-input matches the snapshot: prompt-inp { "cancelable": false, "description": "Called whenever a user clicks the action button or presses the "Enter" key. -The event \`detail\` contains the current value of the field.", +The event \`detail\` contains the current value as a string and an array of tokens. + +When \`menus\` is defined, the \`value\` is derived from \`tokensToText(tokens)\` if provided, otherwise from the default token-to-text conversion.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "PromptInputProps.ActionDetail", "properties": [ + { + "name": "tokens", + "optional": false, + "type": "Array", + }, { "name": "value", "optional": false, @@ -18852,7 +18859,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ActionDetail", "name": "onAction", }, { @@ -18863,10 +18870,17 @@ The event \`detail\` contains the current value of the field.", { "cancelable": false, "description": "Called whenever a user changes the input value (by typing or pasting). -The event \`detail\` contains the current value of the field.", +The event \`detail\` contains the current value as a string and an array of tokens. + +When \`menus\` is defined, the \`value\` is derived from \`tokensToText(tokens)\` if provided, otherwise from the default token-to-text conversion.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "PromptInputProps.ChangeDetail", "properties": [ + { + "name": "tokens", + "optional": false, + "type": "Array", + }, { "name": "value", "optional": false, @@ -18875,7 +18889,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ChangeDetail", "name": "onChange", }, { @@ -18981,6 +18995,348 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "detailType": "BaseKeyDetail", "name": "onKeyUp", }, + { + "cancelable": false, + "description": "Called when the user types to filter options in manual filtering mode for a menu. +Use this to filter the options based on the filtering text. + +The detail object contains: +- \`menuId\` - The ID of the menu that triggered the event. +- \`filteringText\` - The text to use for filtering options.", + "detailInlineType": { + "name": "PromptInputProps.MenuFilterDetail", + "properties": [ + { + "name": "filteringText", + "optional": false, + "type": "string", + }, + { + "name": "menuId", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuFilterDetail", + "name": "onMenuFilter", + }, + { + "cancelable": false, + "description": "Called whenever a user selects an option in a menu.", + "detailInlineType": { + "name": "PromptInputProps.MenuItemSelectDetail", + "properties": [ + { + "name": "menuId", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "OptionDefinition", + "properties": [ + { + "name": "__labelPrefix", + "optional": true, + "type": "string", + }, + { + "name": "description", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "name": "disabledReason", + "optional": true, + "type": "string", + }, + { + "name": "filteringTags", + "optional": true, + "type": "ReadonlyArray", + }, + { + "name": "iconAlt", + "optional": true, + "type": "string", + }, + { + "name": "iconAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "map", + "filter", + "key", + "file", + "pause", + "play", + "microphone", + "remove", + "copy", + "menu", + "script", + "close", + "status-pending", + "refresh", + "external", + "history", + "group", + "calendar", + "ellipsis", + "zoom-in", + "zoom-out", + "security", + "download", + "edit", + "add-plus", + "anchor-link", + "angle-left-double", + "angle-left", + "angle-right-double", + "angle-right", + "angle-up", + "angle-down", + "arrow-left", + "arrow-right", + "arrow-up", + "arrow-down", + "at-symbol", + "audio-full", + "audio-half", + "audio-off", + "backward-10-seconds", + "bug", + "call", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "closed-caption", + "closed-caption-unavailable", + "command-prompt", + "delete-marker", + "drag-indicator", + "envelope", + "exit-full-screen", + "expand", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "gen-ai", + "globe", + "grid-view", + "group-active", + "heart", + "heart-filled", + "insert-row", + "keyboard", + "list-view", + "location-pin", + "lock-private", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "redo", + "resize-area", + "settings", + "send", + "share", + "shrink", + "slash", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-not-started", + "status-positive", + "status-stopped", + "status-warning", + "stop-circle", + "subtract-minus", + "suggestions", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-to-fit", + ], + }, + "name": "iconName", + "optional": true, + "type": "string", + }, + { + "name": "iconSvg", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "iconUrl", + "optional": true, + "type": "string", + }, + { + "name": "label", + "optional": true, + "type": "string", + }, + { + "name": "labelContent", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "labelTag", + "optional": true, + "type": "string", + }, + { + "name": "lang", + "optional": true, + "type": "string", + }, + { + "name": "tags", + "optional": true, + "type": "ReadonlyArray", + }, + { + "name": "value", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "option", + "optional": false, + "type": "OptionDefinition", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuItemSelectDetail", + "name": "onMenuItemSelect", + }, + { + "cancelable": false, + "description": "Use this event to implement the asynchronous behavior for menus. + +The event is called in the following situations: +- The user scrolls to the end of the list of options, if \`statusType\` is set to \`pending\`. +- The user clicks on the recovery button in the error state. +- The user types after the trigger character. +- The menu is opened. + +The detail object contains the following properties: +- \`menuId\` - The ID of the menu that triggered the event. +- \`filteringText\` - The value that you need to use to fetch options. +- \`firstPage\` - Indicates that you should fetch the first page of options that match the \`filteringText\`. +- \`samePage\` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button).", + "detailInlineType": { + "name": "PromptInputProps.MenuLoadItemsDetail", + "properties": [ + { + "name": "filteringText", + "optional": false, + "type": "string", + }, + { + "name": "firstPage", + "optional": false, + "type": "boolean", + }, + { + "name": "menuId", + "optional": false, + "type": "string", + }, + { + "name": "samePage", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuLoadItemsDetail", + "name": "onMenuLoadItems", + }, + { + "cancelable": false, + "description": "Called when the user scrolls to the end of the options list in a menu and more items are available. +Use this to load additional pages of options for pagination. + +The detail object contains the \`menuId\` of the menu that triggered the event.", + "detailInlineType": { + "name": "PromptInputProps.MenuLoadMoreItemsDetail", + "properties": [ + { + "name": "menuId", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuLoadMoreItemsDetail", + "name": "onMenuLoadMoreItems", + }, + { + "cancelable": false, + "description": "Called when the user removes the active mode.", + "name": "onModeRemoved", + }, ], "functions": [ { @@ -18989,6 +19345,22 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "parameters": [], "returnType": "void", }, + { + "description": "Inserts text at the current cursor position (or at a specified position). +This properly triggers keyboard and input events, including menu detection when \`menus\` is defined.", + "name": "insertText", + "parameters": [ + { + "name": "text", + "type": "string", + }, + { + "name": "position", + "type": "number", + }, + ], + "returnType": "void", + }, { "description": "Selects all text in the textarea control.", "name": "select", @@ -19022,6 +19394,7 @@ common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-set "name": "PromptInput", "properties": [ { + "deprecatedTag": "Use \`i18nStrings.actionButtonAriaLabel\` instead.", "description": "Adds an aria-label to the action button.", "i18nTag": true, "name": "actionButtonAriaLabel", @@ -19201,9 +19574,9 @@ and set the property to a string of each ID separated by spaces (for example, \` "type": "string", }, { - "description": "Adds an \`aria-label\` to the native control. - -Use this if you don't have a visible label for this control.", + "deprecatedTag": "Use \`i18nStrings.ariaLabel\` instead.", + "description": "Adds an aria-label to the input element.", + "i18nTag": true, "name": "ariaLabel", "optional": true, "type": "string", @@ -19232,7 +19605,9 @@ In some cases it might be appropriate to disable autocomplete (for example, for To use it correctly, set the \`name\` property. You can either provide a boolean value to set the property to "on" or "off", or specify a string value -for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute.", +for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute. + +Note: When \`menus\` is defined, autocomplete will not function.", "inlineType": { "name": "string | boolean", "type": "union", @@ -19310,6 +19685,82 @@ receive focus.", "optional": true, "type": "boolean", }, + { + "description": "By default, the menu height is constrained to fit inside the height of its next scrollable container element. +Enabling this property will allow the menu to extend beyond that container by using fixed positioning and +[React Portals](https://reactjs.org/docs/portals.html). + +Set this property if the menu would otherwise be constrained by a scrollable container, +for example inside table and split view layouts. + +We recommend you use discretion, and don't enable this property unless necessary +because fixed positioning results in a slight, visible lag when scrolling complex pages.", + "name": "expandMenusToViewport", + "optional": true, + "type": "boolean", + }, + { + "description": "An object containing all the localized strings required by the component. + +- \`ariaLabel\` (string) - Adds an aria-label to the input element. +- \`actionButtonAriaLabel\` (string) - Adds an aria-label to the action button. +- \`menuErrorIconAriaLabel\` (string) - Provides a text alternative for the error icon in the error message in menus. +- \`menuRecoveryText\` (string) - Specifies the text for the recovery button in menus. The text is displayed next to the error text. +- \`menuLoadingText\` (string) - Specifies the text to display when menus are in a loading state. +- \`menuFinishedText\` (string) - Specifies the text to display when menus have finished loading all items. +- \`menuErrorText\` (string) - Specifies the text to display when menus encounter an error while loading. +- \`selectedMenuItemAriaLabel\` (string) - Specifies the localized string that describes an option as being selected.", + "i18nTag": true, + "inlineType": { + "name": "PromptInputProps.I18nStrings", + "properties": [ + { + "name": "actionButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "name": "menuErrorIconAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "menuErrorText", + "optional": true, + "type": "string", + }, + { + "name": "menuFinishedText", + "optional": true, + "type": "string", + }, + { + "name": "menuLoadingText", + "optional": true, + "type": "string", + }, + { + "name": "menuRecoveryText", + "optional": true, + "type": "string", + }, + { + "name": "selectedMenuItemAriaLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "PromptInputProps.I18nStrings", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -19337,6 +19788,13 @@ Defaults to 3. Use -1 for infinite rows.", "optional": true, "type": "number", }, + { + "description": "Menus that can be triggered via specific symbols (e.g., "/" or "@"). +For menus only relevant to triggers at the start of the input, set \`useAtStart: true\`, defaults to \`false\`.", + "name": "menus", + "optional": true, + "type": "Array", + }, { "defaultValue": "1", "description": "Specifies the minimum number of lines of text to set the height to.", @@ -19345,7 +19803,37 @@ Defaults to 3. Use -1 for infinite rows.", "type": "number", }, { - "description": "Specifies the name of the control used in HTML forms.", + "description": "Specifies the active mode (e.g., /dev, /creative).", + "inlineType": { + "name": "PromptInputProps.ModeToken", + "properties": [ + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "label", + "optional": false, + "type": "string", + }, + { + "name": "value", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "name": "mode", + "optional": true, + "type": "PromptInputProps.ModeToken", + }, + { + "description": "Specifies the name of the prompt input for form submissions. + +When \`tokens\` is set, the value will be the \`tokensToText\` output if provided, +else it will be the concatenated \`value\` properties from \`tokens\`.", "name": "name", "optional": true, "type": "string", @@ -19356,7 +19844,8 @@ Some attributes will be automatically combined with internal attribute values: - \`className\` will be appended. - Event handlers will be chained, unless the default is prevented. -We do not support using this attribute to apply custom styling.", +We do not support using this attribute to apply custom styling. +If \`tokens\` is defined, nativeTextareaAttributes will be ignored.", "inlineType": { "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", "type": "union", @@ -19388,6 +19877,35 @@ Don't use read-only inputs outside a form.", "optional": true, "type": "boolean", }, + { + "description": "Overrides the element that is announced to screen readers in menus +when the highlighted option changes. By default, this announces +the option's name and properties, and its selected state if +the \`selectedLabel\` property is defined. +The highlighted option is provided, and its group (if groups +are used and it differs from the group of the previously highlighted option). + +For more information, see the +[accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines).", + "inlineType": { + "name": "AutosuggestProps.ContainingOptionAndGroupString", + "parameters": [ + { + "name": "option", + "type": "OptionDefinition", + }, + { + "name": "group", + "type": "AutosuggestProps.OptionGroup", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderHighlightedMenuItemAriaLive", + "optional": true, + "type": "AutosuggestProps.ContainingOptionAndGroupString", + }, { "description": "Specifies the value of the \`spellcheck\` attribute on the native control. This value controls the native browser capability to check for spelling/grammar errors. @@ -19632,7 +20150,45 @@ inadvertently sending data (such as user passwords) to third parties.", "type": "PromptInputProps.Style", }, { - "description": "Specifies the text entered into the form element.", + "description": "Specifies the content of the prompt input when using token mode. + +All tokens use the same unified structure with a \`value\` property: +- Text tokens: \`value\` contains the text content +- Reference tokens: \`value\` contains the reference value, \`label\` for display (e.g., '@john')", + "name": "tokens", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "Custom function to transform tokens into plain text for the \`value\` field in \`onChange\` and \`onAction\` events +and for the hidden input when \`name\` is specified. + +If not provided, the default implementation is: +\`\`\` +tokens.map(token => token.value).join(''); +\`\`\` + +Use this to customize serialization, for example: +- Using \`label\` instead of \`value\` for reference tokens +- Adding custom formatting or separators between tokens", + "inlineType": { + "name": "(tokens: ReadonlyArray) => string", + "parameters": [ + { + "name": "tokens", + "type": "ReadonlyArray", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokensToText", + "optional": true, + "type": "((tokens: ReadonlyArray) => string)", + }, + { + "description": "Specifies the content of the prompt input. +When \`tokens\` is defined, this represents the plain text equivalent of the tokens.", "name": "value", "optional": false, "type": "string", @@ -37804,6 +38360,21 @@ If not specified, the method returns the result text that is currently displayed ], }, }, + { + "description": "Finds the contentEditable element used when menus are defined. +Returns null if the component does not have menus defined.", + "name": "findContentEditableElement", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLDivElement", + }, + ], + }, + }, { "name": "findCustomPrimaryAction", "parameters": [], @@ -37818,6 +38389,29 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "name": "findMenu", + "parameters": [ + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "PromptInputMenuWrapper", + }, + }, + { + "description": "Finds the native textarea element. + +Note: When menus are defined, the component uses a contentEditable element instead of a textarea. +In this case, this method may fail to find the textarea element. Use findContentEditableElement() +or the getValue()/setValue() methods instead.", "name": "findNativeTextarea", "parameters": [], "returnType": { @@ -37857,11 +38451,19 @@ If not specified, the method returns the result text that is currently displayed ], }, }, + { + "name": "getTextareaValue", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "string", + }, + }, { "description": "Gets the value of the component. -Returns the current value of the textarea.", - "name": "getTextareaValue", +Returns the current value of the textarea (when no menus are defined) or the text content of the contentEditable element (when menus are defined).", + "name": "getValue", "parameters": [], "returnType": { "isNullable": false, @@ -37869,7 +38471,78 @@ Returns the current value of the textarea.", }, }, { - "description": "Sets the value of the component and calls the onChange handler.", + "name": "isMenuOpen", + "parameters": [ + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + { + "description": "Selects an option from the menu by simulating mouse events.", + "name": "selectMenuOption", + "parameters": [ + { + "description": "1-based index of the option to select", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { + "description": "Selects an option from the menu by simulating mouse events.", + "name": "selectMenuOptionByValue", + "parameters": [ + { + "description": "value of option to select", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { "name": "setTextareaValue", "parameters": [ { @@ -37886,9 +38559,96 @@ Returns the current value of the textarea.", "name": "void", }, }, + { + "description": "Sets the value of the component by directly setting text content. +This does NOT trigger menu detection. Use the component ref's insertText() method +to simulate typing and trigger menus.", + "name": "setValue", + "parameters": [ + { + "description": "String value to set the component to.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "name": "findOpenMenu", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns an option from the menu.", + "name": "findOption", + "parameters": [ + { + "description": "1-based index of the option to select.", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionWrapper", + }, + }, + { + "description": "Returns an option from the menu by its value", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The 'value' of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionWrapper", + }, + }, + { + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "OptionWrapper", + }, + ], + }, + }, + ], + "name": "PromptInputMenuWrapper", + }, { "methods": [ { @@ -46917,6 +47677,16 @@ If not specified, the method returns the result text that is currently displayed "name": "ElementWrapper", }, }, + { + "description": "Finds the contentEditable element used when menus are defined. +Returns null if the component does not have menus defined.", + "name": "findContentEditableElement", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findCustomPrimaryAction", "parameters": [], @@ -46926,6 +47696,31 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "name": "findMenu", + "parameters": [ + { + "defaultValue": "{ + expandMenusToViewport: false + }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "PromptInputMenuWrapper", + }, + }, + { + "description": "Finds the native textarea element. + +Note: When menus are defined, the component uses a contentEditable element instead of a textarea. +In this case, this method may fail to find the textarea element. Use findContentEditableElement() +or the getValue()/setValue() methods instead.", "name": "findNativeTextarea", "parameters": [], "returnType": { @@ -46953,6 +47748,68 @@ If not specified, the method returns the result text that is currently displayed ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "name": "findOpenMenu", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns an option from the menu.", + "name": "findOption", + "parameters": [ + { + "description": "1-based index of the option to select.", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionWrapper", + }, + }, + { + "description": "Returns an option from the menu by its value", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The 'value' of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionWrapper", + }, + }, + { + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "OptionWrapper", + }, + ], + }, + }, + ], + "name": "PromptInputMenuWrapper", + }, { "methods": [ { diff --git a/src/internal/components/dropdown/dropdown-fit-handler.ts b/src/internal/components/dropdown/dropdown-fit-handler.ts index ffb1af613e..26d84495bd 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -232,6 +232,7 @@ export const getDropdownPosition = ({ stretchHeight = false, isMobile = false, stretchBeyondTriggerWidth = false, + forcePosition, }: { triggerElement: HTMLElement; dropdownElement: HTMLElement; @@ -242,6 +243,7 @@ export const getDropdownPosition = ({ stretchHeight?: boolean; isMobile?: boolean; stretchBeyondTriggerWidth?: boolean; + forcePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; }): DropdownPosition => { // Determine the space available around the dropdown that it can grow in const availableSpace = getAvailableSpace({ @@ -262,19 +264,24 @@ export const getDropdownPosition = ({ let insetInlineStart: number | null = null; let inlineSize = idealWidth; - //1. Can it be positioned with ideal width to the right? - if (idealWidth <= availableSpace.inlineEnd) { - dropInlineStart = false; - //2. Can it be positioned with ideal width to the left? - } else if (idealWidth <= availableSpace.inlineStart) { - dropInlineStart = true; - //3. Fit into biggest available space either on left or right + // Handle forced horizontal position + if (forcePosition) { + dropInlineStart = forcePosition.endsWith('-left'); } else { - dropInlineStart = availableSpace.inlineStart > availableSpace.inlineEnd; - inlineSize = Math.max(availableSpace.inlineStart, availableSpace.inlineEnd, minWidth); + //1. Can it be positioned with ideal width to the right? + if (idealWidth <= availableSpace.inlineEnd) { + dropInlineStart = false; + //2. Can it be positioned with ideal width to the left? + } else if (idealWidth <= availableSpace.inlineStart) { + dropInlineStart = true; + //3. Fit into biggest available space either on left or right + } else { + dropInlineStart = availableSpace.inlineStart > availableSpace.inlineEnd; + inlineSize = Math.max(availableSpace.inlineStart, availableSpace.inlineEnd, minWidth); + } } - if (preferCenter) { + if (preferCenter && !forcePosition) { const spillOver = (idealWidth - triggerInlineSize) / 2; // availableSpace always includes the trigger width, but we want to exclude that @@ -287,8 +294,9 @@ export const getDropdownPosition = ({ } } - const dropBlockStart = - availableSpace.blockEnd < dropdownElement.offsetHeight && availableSpace.blockStart > availableSpace.blockEnd; + const dropBlockStart = forcePosition + ? forcePosition.startsWith('top-') + : availableSpace.blockEnd < dropdownElement.offsetHeight && availableSpace.blockStart > availableSpace.blockEnd; const availableHeight = dropBlockStart ? availableSpace.blockStart : availableSpace.blockEnd; // Try and crop the bottom item when all options can't be displayed, affordance for "there's more" const croppedHeight = Math.max(stretchHeight ? availableHeight : Math.floor(availableHeight / 31) * 31 + 16, 15); @@ -361,7 +369,8 @@ export const calculatePosition = ( stretchHeight: boolean, isMobile: boolean, minWidth?: number, - stretchBeyondTriggerWidth?: boolean + stretchBeyondTriggerWidth?: boolean, + forcePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' ): [DropdownPosition, LogicalDOMRect] => { // cleaning previously assigned values, // so that they are not reused in case of screen resize and similar events @@ -393,6 +402,7 @@ export const calculatePosition = ( stretchHeight, isMobile, stretchBeyondTriggerWidth, + forcePosition, }); const triggerBox = getLogicalBoundingClientRect(triggerElement); return [position, triggerBox]; diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index 7dbc402aeb..509a289ad6 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -177,6 +177,8 @@ const Dropdown = ({ dropdownContentRole, ariaLabelledby, ariaDescribedby, + forcePosition, + forceMobile = false, }: DropdownProps) => { const wrapperRef = useRef(null); const triggerRef = useRef(null); @@ -236,7 +238,7 @@ const Dropdown = ({ position, dropdownElement: target, triggerRect: triggerBox, - isMobile, + isMobile: forceMobile || isMobile, }); // Keep track of the initial dropdown position and direction. // Dropdown direction doesn't need to change as the user scrolls, just needs to stay attached to the trigger. @@ -328,7 +330,8 @@ const Dropdown = ({ stretchHeight, isMobile, minWidth, - stretchBeyondTriggerWidth + stretchBeyondTriggerWidth, + forcePosition ), dropdownRef.current, verticalContainerRef.current @@ -389,7 +392,7 @@ const Dropdown = ({ position: fixedPosition.current, dropdownElement: dropdownRef.current, triggerRect: getLogicalBoundingClientRect(triggerRef.current), - isMobile, + isMobile: forceMobile || isMobile, }); } }; @@ -402,7 +405,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport, isMobile]); + }, [open, expandToViewport, isMobile, forceMobile]); const referrerId = useUniqueId(); diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index 27c611d0b9..b59ac01363 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -155,6 +155,16 @@ export interface DropdownProps extends ExpandToViewport { * Describedby for the dropdown (recommended when role="dialog") */ ariaDescribedby?: string; + + /** + * Force a specific dropdown position + */ + forcePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + + /** + * Force mobile behavior (useful for full-width dropdowns with groups) + */ + forceMobile?: boolean; } export interface ExpandToViewport { diff --git a/src/prompt-input/index.tsx b/src/prompt-input/index.tsx index fb8c5a8884..975a374e20 100644 --- a/src/prompt-input/index.tsx +++ b/src/prompt-input/index.tsx @@ -10,7 +10,7 @@ import InternalPromptInput from './internal'; export { PromptInputProps }; -const PromptInput = React.forwardRef( +const PromptInput = React.forwardRef( ( { autoFocus, @@ -22,8 +22,8 @@ const PromptInput = React.forwardRef( minRows = 1, maxRows = 3, ...props - }: PromptInputProps, - ref: React.Ref + }, + ref ) => { const baseComponentProps = useBaseComponent('PromptInput', { props: { diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index e0e297ac2b..a2701ea682 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -4,19 +4,9 @@ import React from 'react'; import { AutosuggestProps } from '../autosuggest/interfaces'; import { IconProps } from '../icon/interfaces'; -import { - BaseInputProps, - InputAutoComplete, - InputAutoCorrect, - InputKeyEvents, - InputSpellcheck, -} from '../input/interfaces'; +import { BaseInputProps, InputAutoCorrect, InputKeyEvents, InputSpellcheck } from '../input/interfaces'; import { BaseComponentProps } from '../internal/base-component'; -import { - BaseDropdownHostProps, - OptionsFilteringType, - OptionsLoadItemsDetail, -} from '../internal/components/dropdown/interfaces'; +import { BaseDropdownHostProps, OptionsFilteringType } from '../internal/components/dropdown/interfaces'; import { DropdownStatusProps } from '../internal/components/dropdown-status'; import { OptionDefinition } from '../internal/components/option/interfaces'; import { FormFieldValidationControlProps } from '../internal/context/form-field-context'; @@ -27,10 +17,9 @@ import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps - extends Omit, + extends Omit, InputKeyEvents, InputAutoCorrect, - InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { @@ -43,9 +32,22 @@ export interface PromptInputProps name?: string; /** - * Specifies the content of the prompt input, not in use if `tokens` is defined. + * Specifies whether to enable a browser's autocomplete functionality for this input. + * In some cases it might be appropriate to disable autocomplete (for example, for security-sensitive fields). + * To use it correctly, set the `name` property. + * + * You can either provide a boolean value to set the property to "on" or "off", or specify a string value + * for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute. + * + * Note: When `menus` is defined, autocomplete will not function. + */ + autoComplete?: boolean | string; + + /** + * Specifies the content of the prompt input. + * When `tokens` is defined, this represents the plain text equivalent of the tokens. */ - value?: string; + value: string; /** * Specifies the content of the prompt input when using token mode. @@ -53,28 +55,27 @@ export interface PromptInputProps * All tokens use the same unified structure with a `value` property: * - Text tokens: `value` contains the text content * - Reference tokens: `value` contains the reference value, `label` for display (e.g., '@john') - * - Mode tokens: Use the `mode` prop instead of including in this array - * - * When defined, `autocomplete` will no longer function. */ tokens?: readonly PromptInputProps.InputToken[]; /** * Specifies the active mode (e.g., /dev, /creative). - * Set `type` to `mode`. */ - mode?: PromptInputProps.InputToken; + mode?: PromptInputProps.ModeToken; /** * Called when the user removes the active mode. */ - onModeRemoved?: NonCancelableEventHandler; + onModeRemoved?: NonCancelableEventHandler; /** * Custom function to transform tokens into plain text for the `value` field in `onChange` and `onAction` events * and for the hidden input when `name` is specified. * - * If not provided, the default implementation concatenates the `value` property from all tokens. + * If not provided, the default implementation is: + * ``` + * tokens.map(token => token.value).join(''); + * ``` * * Use this to customize serialization, for example: * - Using `label` instead of `value` for reference tokens @@ -84,17 +85,17 @@ export interface PromptInputProps /** * Called whenever a user changes the input value (by typing or pasting). - * The event `detail` contains the current value as a React.ReactNode. + * The event `detail` contains the current value as a string and an array of tokens. * - * When `tokens` is defined this will return `undefined` for `value` and an array of `tokens` representing the current content in the input. + * When `menus` is defined, the `value` is derived from `tokensToText(tokens)` if provided, otherwise from the default token-to-text conversion. */ onChange?: NonCancelableEventHandler; /** * Called whenever a user clicks the action button or presses the "Enter" key. - * The event `detail` contains the current value of the field. + * The event `detail` contains the current value as a string and an array of tokens. * - * When `tokens` is defined this will return `undefined` for `value` and an array of `tokens` representing the current content in the input. + * When `menus` is defined, the `value` is derived from `tokensToText(tokens)` if provided, otherwise from the default token-to-text conversion. */ onAction?: NonCancelableEventHandler; @@ -139,9 +140,17 @@ export interface PromptInputProps */ actionButtonIconAlt?: string; + /** + * Adds an aria-label to the input element. + * @i18n + * @deprecated Use `i18nStrings.ariaLabel` instead. + */ + ariaLabel?: string; + /** * Adds an aria-label to the action button. * @i18n + * @deprecated Use `i18nStrings.actionButtonAriaLabel` instead. */ actionButtonAriaLabel?: string; @@ -197,12 +206,12 @@ export interface PromptInputProps menus?: PromptInputProps.MenuDefinition[]; /** - * Called whenever a user selects an option in the menu. + * Called whenever a user selects an option in a menu. */ - onMenuSelect?: NonCancelableEventHandler; + onMenuItemSelect?: NonCancelableEventHandler; /** - * Use this event to implement the asynchronous behavior for the menu. + * Use this event to implement the asynchronous behavior for menus. * * The event is called in the following situations: * - The user scrolls to the end of the list of options, if `statusType` is set to `pending`. @@ -211,6 +220,7 @@ export interface PromptInputProps * - The menu is opened. * * The detail object contains the following properties: + * - `menuId` - The ID of the menu that triggered the event. * - `filteringText` - The value that you need to use to fetch options. * - `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. * - `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). @@ -218,18 +228,37 @@ export interface PromptInputProps onMenuLoadItems?: NonCancelableEventHandler; /** - * Provides a text alternative for the error icon in the error message in menus. - * @i18n + * Called when the user scrolls to the end of the options list in a menu and more items are available. + * Use this to load additional pages of options for pagination. + * + * The detail object contains the `menuId` of the menu that triggered the event. */ - menuErrorIconAriaLabel?: string; + onMenuLoadMoreItems?: NonCancelableEventHandler; /** - * Specifies the localized string that describes an option as being selected. - * This is required to provide a good screen reader experience. For more information, see the - * [accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines). + * Called when the user types to filter options in manual filtering mode for a menu. + * Use this to filter the options based on the filtering text. + * + * The detail object contains: + * - `menuId` - The ID of the menu that triggered the event. + * - `filteringText` - The text to use for filtering options. + */ + onMenuFilter?: NonCancelableEventHandler; + + /** + * An object containing all the localized strings required by the component. + * + * - `ariaLabel` (string) - Adds an aria-label to the input element. + * - `actionButtonAriaLabel` (string) - Adds an aria-label to the action button. + * - `menuErrorIconAriaLabel` (string) - Provides a text alternative for the error icon in the error message in menus. + * - `menuRecoveryText` (string) - Specifies the text for the recovery button in menus. The text is displayed next to the error text. + * - `menuLoadingText` (string) - Specifies the text to display when menus are in a loading state. + * - `menuFinishedText` (string) - Specifies the text to display when menus have finished loading all items. + * - `menuErrorText` (string) - Specifies the text to display when menus encounter an error while loading. + * - `selectedMenuItemAriaLabel` (string) - Specifies the localized string that describes an option as being selected. * @i18n */ - selectedMenuItemAriaLabel?: string; + i18nStrings?: PromptInputProps.I18nStrings; /** * Overrides the element that is announced to screen readers in menus @@ -244,6 +273,19 @@ export interface PromptInputProps */ renderHighlightedMenuItemAriaLive?: AutosuggestProps.ContainingOptionAndGroupString; + /** + * By default, the menu height is constrained to fit inside the height of its next scrollable container element. + * Enabling this property will allow the menu to extend beyond that container by using fixed positioning and + * [React Portals](https://reactjs.org/docs/portals.html). + * + * Set this property if the menu would otherwise be constrained by a scrollable container, + * for example inside table and split view layouts. + * + * We recommend you use discretion, and don't enable this property unless necessary + * because fixed positioning results in a slight, visible lag when scrolling complex pages. + */ + expandMenusToViewport?: boolean; + /** * Attributes to add to the native `textarea` element. * Some attributes will be automatically combined with internal attribute values: @@ -266,43 +308,66 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; - export interface InputToken { - type: 'text' | 'reference' | 'mode'; - id?: string; - label?: string; - value?: string; + export interface I18nStrings { + ariaLabel?: string; + actionButtonAriaLabel?: string; + menuErrorIconAriaLabel?: string; + menuRecoveryText?: string; + menuLoadingText?: string; + menuFinishedText?: string; + menuErrorText?: string; + selectedMenuItemAriaLabel?: string; + } + + export interface TextToken { + type: 'text'; + value: string; + } + + export interface ReferenceToken { + type: 'reference'; + id: string; + label: string; + value: string; } + export type ModeToken = Omit; + + export type InputToken = TextToken | ReferenceToken; + export interface ChangeDetail { - value?: string; - tokens?: InputToken[]; - /** - * @experimental Prototype feature - communicates mode detection. - * Will be replaced by menu system in final implementation. - */ - mode?: InputToken; + value: string; + tokens: InputToken[]; } export interface ActionDetail { - value?: string; - tokens?: InputToken[]; + value: string; + tokens: InputToken[]; } - export interface MenuSelectDetail { + export interface MenuItemSelectDetail { menuId: string; option: OptionDefinition; } - export interface MenuLoadItemsDetail extends OptionsLoadItemsDetail { + export interface MenuLoadItemsDetail { menuId: string; + filteringText: string; + firstPage: boolean; + samePage: boolean; } - export interface ModeChangeDetail { - mode?: InputToken; + export interface MenuLoadMoreItemsDetail { + menuId: string; + } + + export interface MenuFilterDetail { + menuId: string; + filteringText: string; } export interface MenuDefinition - extends Omit, + extends Pick, Pick { /** * The unique identifier for this menu. @@ -316,11 +381,14 @@ export namespace PromptInputProps { /** * Set `useAtStart=true` for menus where a trigger should only be detected at the start of input. + * Set this for menus designated to modes or actions. + * + * Menus with `useAtStart=true` create tokens with `type='mode'`. */ useAtStart?: boolean; /** - * Specifies an array of options that are displayed to the user as a dropdown list. + * Specifies an array of options that are displayed to the user as a list. * The options can be grouped using `OptionGroup` objects. * * #### Option @@ -356,8 +424,8 @@ export namespace PromptInputProps { /** * Determines how filtering is applied to the list of `options`: * - * * `auto` - The component will automatically filter options based on user input. - * * `manual` - You will set up `onChange` or `onMenuLoadItems` event listeners and filter options on your side or request + * - `auto` - The component will automatically filter options based on user input. + * - `manual` - You will set up `onMenuFilter` event listeners and filter options on your side or request * them from server. * * By default the component will filter the provided `options` based on the value of the filtering input field. @@ -365,19 +433,12 @@ export namespace PromptInputProps { * are displayed in the list of options. * * If you set this property to `manual`, this default filtering mechanism is disabled and all provided `options` are - * displayed in the dropdown list. In that case make sure that you use the `onChange` or `onMenuLoadItems` events in order + * displayed in the menu. In that case make sure that you use the `onMenuFilter` event in order * to set the `options` property to the options that are relevant for the user, given the filtering input value. * * Note: Manual filtering doesn't disable match highlighting. **/ filteringType?: Exclude; - - /** - * Specifies the text for the recovery button. The text is displayed next to the error text. - * Use the `onMenuLoadItems` event to perform a recovery action (for example, retrying the request). - * @i18n - */ - recoveryText?: string; } export interface Ref { @@ -399,6 +460,12 @@ export namespace PromptInputProps { * common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-setselectionrange-incompatible-with-react-hooks */ setSelectionRange(start: number | null, end: number | null, direction?: 'forward' | 'backward' | 'none'): void; + + /** + * Inserts text at the current cursor position (or at a specified position). + * This properly triggers keyboard and input events, including menu detection when `menus` is defined. + */ + insertText(text: string, position?: number): void; } export interface Style { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index 1fc970b99d..48cd310c9b 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -1,14 +1,17 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import clsx from 'clsx'; -import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; +import { useDensityMode, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; import { convertAutoComplete } from '../input/utils'; import { getBaseProps } from '../internal/base-component'; +import Dropdown from '../internal/components/dropdown'; +import DropdownFooter from '../internal/components/dropdown-footer'; +import { useDropdownStatus } from '../internal/components/dropdown-status'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; import * as designTokens from '../internal/generated/styles/tokens'; @@ -17,18 +20,22 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { SomeRequired } from '../internal/types'; import WithNativeAttributes from '../internal/utils/with-native-attributes'; import { PromptInputProps } from './interfaces'; -import { findNodeAndOffset, restoreSelection, saveSelection, setCursorToEnd } from './selection-utils'; +import { MenuItem, useMenuItems } from './menus/menu-controller'; +import { useMenuLoadMore } from './menus/menu-load-more-controller'; +import MenuOptionsList from './menus/menu-options-list'; import { getPromptInputStyles } from './styles'; -import { domToTokenArray, getPromptText, renderTokensToDOM } from './token-utils'; +import { getPromptText } from './tokens/token-utils'; +import { useEditableTokens } from './tokens/use-editable-tokens'; +import { createCursorManager } from './utils/cursor-utils'; +import { createKeyboardHandlers } from './utils/keyboard-handlers'; import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; - interface InternalPromptInputProps extends SomeRequired, InternalBaseComponentProps {} -const InternalPromptInput = React.forwardRef( +const InternalPromptInput = React.forwardRef( ( { value, @@ -67,43 +74,64 @@ const InternalPromptInput = React.forwardRef( mode, onModeRemoved, menus, - onMenuSelect, + onMenuItemSelect, + onMenuFilter, onMenuLoadItems, - menuErrorIconAriaLabel, + onMenuLoadMoreItems, + i18nStrings, + expandMenusToViewport, __internalRootRef, ...rest }: InternalPromptInputProps, - ref: Ref + ref ) => { const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = useFormFieldContext(rest); const baseProps = getBaseProps(rest); + // i18n strings with fallback to deprecated properties + const effectiveActionButtonAriaLabel = i18nStrings?.actionButtonAriaLabel ?? actionButtonAriaLabel; + + // Menu state - event-driven trigger detection + // Only open menu when trigger character is pressed, then track filter text + const [detectedTrigger, setDetectedTrigger] = useState<{ + menuId: string; + filterText: string; + triggerPosition: number; + } | null>(null); + + // Derive menu state from detected trigger + const activeMenu = useMemo( + () => (detectedTrigger ? (menus?.find(m => m.id === detectedTrigger.menuId) ?? null) : null), + [detectedTrigger, menus] + ); + const menuIsOpen = !!activeMenu; + const menuFilterText = detectedTrigger?.filterText ?? ''; + const menuTriggerPosition = detectedTrigger?.triggerPosition ?? 0; + // Refs const textareaRef = useRef(null); const editableElementRef = useRef(null); const reactContainersRef = useRef>(new Set()); - const isRenderingRef = useRef(false); - const lastTokensRef = useRef(tokens); - const savedSelectionRef = useRef(null); - const lastModeRef = useRef(mode); // Mode detection const isRefresh = useVisualRefresh(); useDensityMode(textareaRef); useDensityMode(editableElementRef); - const isTokenMode = !!tokens; + const isTokenMode = !!menus; // Style constants const PADDING = isRefresh ? designTokens.spaceXxs : designTokens.spaceXxxs; const LINE_HEIGHT = designTokens.lineHeightBodyM; + // Ref to store the keydown handler for insertText method + const keydownHandlerRef = useRef<((event: React.KeyboardEvent) => void) | null>(null); + useImperativeHandle( ref, () => ({ focus(...args: Parameters) { if (isTokenMode) { editableElementRef.current?.focus(...args); - restoreSelection(savedSelectionRef.current); } else { textareaRef.current?.focus(...args); } @@ -124,23 +152,68 @@ const InternalPromptInput = React.forwardRef( setSelectionRange(...args: Parameters) { if (isTokenMode && editableElementRef.current) { const [start, end] = args; - const selection = window.getSelection(); - if (!selection) { - return; + const cursorManager = createCursorManager(editableElementRef.current); + + if (end !== undefined && end !== null && end !== start) { + cursorManager.setRange(start ?? 0, end); + } else { + cursorManager.setPosition(start ?? 0); } + } else { + textareaRef.current?.setSelectionRange(...args); + } + }, + insertText(text: string, position?: number) { + if (!isTokenMode || !editableElementRef.current || !keydownHandlerRef.current) { + return; + } - const startPos = findNodeAndOffset(editableElementRef.current, start ?? 0); - const endPos = findNodeAndOffset(editableElementRef.current, end ?? 0); + const element = editableElementRef.current; - if (startPos && endPos) { - const range = document.createRange(); - range.setStart(startPos.node, startPos.offset); - range.setEnd(endPos.node, endPos.offset); + // Position cursor if specified + if (position !== undefined) { + const cursorManager = createCursorManager(element); + cursorManager.setPosition(position); + } + + // Insert each character to trigger menu detection + for (const char of text) { + // Trigger keydown event + const keydownEvent = { + key: char, + ctrlKey: false, + metaKey: false, + altKey: false, + nativeEvent: new KeyboardEvent('keydown', { key: char, bubbles: true, cancelable: true }), + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.KeyboardEvent; + + keydownHandlerRef.current(keydownEvent); + + // Insert character at cursor + const selection = window.getSelection(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const textNode = document.createTextNode(char); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); + } else { + // JSDOM fallback - append to end and try to restore selection + element.textContent = (element.textContent || '') + char; + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); } - } else { - textareaRef.current?.setSelectionRange(...args); + + // Trigger input event + element.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); } }, }), @@ -178,6 +251,167 @@ const InternalPromptInput = React.forwardRef( } }, [isTokenMode, minRows, maxRows, LINE_HEIGHT, PADDING]); + // Callback for updating filter text when menu is open + // Uses the wrapper element to track the trigger region + const updateMenuFilterText = useCallback(() => { + if (!detectedTrigger || !editableElementRef.current) { + return; + } + + // Find the trigger wrapper element + const wrapper = editableElementRef.current.querySelector( + `[data-menu-trigger="${detectedTrigger.menuId}"]` + ) as HTMLElement; + + if (!wrapper) { + // Wrapper was removed (e.g., user deleted it) - close menu + setDetectedTrigger(null); + return; + } + + // Check if cursor is inside the wrapper + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + const cursorNode = range.startContainer; + + // Walk up to see if we're inside the wrapper + let isInsideWrapper = false; + let node: Node | null = cursorNode; + while (node && node !== editableElementRef.current) { + if (node === wrapper) { + isInsideWrapper = true; + break; + } + node = node.parentNode; + } + + if (!isInsideWrapper) { + // Cursor moved outside wrapper - close menu + setDetectedTrigger(null); + return; + } + + // Extract filter text from wrapper (everything after the trigger char) + const wrapperText = wrapper.textContent || ''; + const newFilterText = wrapperText.substring(1); // Skip trigger character + + // Update filter text if changed + if (newFilterText !== detectedTrigger.filterText) { + setDetectedTrigger({ + ...detectedTrigger, + filterText: newFilterText, + }); + + // Fire filter event + if (onMenuFilter) { + fireNonCancelableEvent(onMenuFilter, { + menuId: detectedTrigger.menuId, + filteringText: newFilterText, + }); + } + } + }, [detectedTrigger, onMenuFilter]); + + // Track desired cursor position for controlled updates (e.g., after menu selection) + // This is in cursor space (tokens = 1 char) + const [desiredCursorPosition, setDesiredCursorPosition] = useState(null); + + // Reset cursor position after it's been applied + useEffect(() => { + if (desiredCursorPosition !== null) { + // Reset after the cursor has been positioned + const timer = setTimeout(() => setDesiredCursorPosition(null), 0); + return () => clearTimeout(timer); + } + }, [desiredCursorPosition, tokens]); + + // Virtual tokens array: [mode, ...inputTokens] - represents actual DOM structure + // This is the single source of truth for all cursor space operations + interface VirtualToken { + type: 'mode' | 'text' | 'reference'; + value: string; + label?: string; + id?: string; + } + const virtualTokens = useMemo(() => { + const result: VirtualToken[] = []; + if (mode) { + result.push({ type: 'mode', value: mode.value, label: mode.label, id: mode.id }); + } + for (const token of tokens ?? []) { + result.push(token as VirtualToken); + } + return result; + }, [mode, tokens]); + + // Use ref for virtualTokens to avoid recreating callbacks + const virtualTokensRef = useRef(virtualTokens); + virtualTokensRef.current = virtualTokens; + + // Convert cursor space position to DOM space position + // Cursor space: mode/reference tokens = 1 char, text = actual length + // DOM space: only TEXT NODES count (tokens are contentEditable=false so they're skipped) + // Use ref to avoid recreating this function on every render + const cursorSpaceToDomSpace = useCallback((cursorSpacePos: number): number => { + let domPos = 0; + let cursorPos = 0; + + for (const token of virtualTokensRef.current) { + if (cursorPos >= cursorSpacePos) { + break; + } + + if (token.type === 'text') { + const remaining = cursorSpacePos - cursorPos; + const tokenLength = token.value.length; + + if (remaining <= tokenLength) { + // Cursor is within this text token + domPos += remaining; + break; + } + + domPos += tokenLength; + cursorPos += tokenLength; + } else { + // Mode or reference token - these are contentEditable=false so they don't count in DOM position + // Just increment cursor position, but NOT dom position + cursorPos += 1; + } + } + + return domPos; + }, []); + + // Helper to get plain text value from tokens or value prop + const getPlainTextValue = useCallback(() => { + if (isTokenMode) { + return tokensToText ? tokensToText(tokens ?? []) : getPromptText(tokens ?? []); + } + return value; + }, [isTokenMode, tokensToText, tokens, value]); + + // Convert cursor space position to DOM space for useEditableTokens + const domCursorPosition = desiredCursorPosition !== null ? cursorSpaceToDomSpace(desiredCursorPosition) : null; + + // Use the editable hook as interface layer between contentEditable DOM and React + const { handleInput } = useEditableTokens({ + elementRef: editableElementRef, + reactContainersRef, + tokens, + mode, + tokensToText, + onChange: detail => fireNonCancelableEvent(onChange, detail), + onModeRemoved: onModeRemoved ? () => fireNonCancelableEvent(onModeRemoved) : undefined, + adjustInputHeight, + disabled: !isTokenMode, + cursorPosition: domCursorPosition, + }); + const handleTextareaKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); @@ -186,222 +420,247 @@ const InternalPromptInput = React.forwardRef( event.currentTarget.form.requestSubmit(); } event.preventDefault(); - const plainText = isTokenMode - ? tokensToText - ? tokensToText(tokens ?? []) - : getPromptText(tokens ?? []) - : value; - fireNonCancelableEvent(onAction, { value: plainText, tokens: [...(tokens ?? [])] }); + fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); } }; const handleTextareaChange = (event: React.ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: event.target.value, tokens: [...(tokens ?? [])] }); + fireNonCancelableEvent(onChange, { + value: event.target.value, + tokens: isTokenMode ? [...(tokens ?? [])] : [], + }); adjustInputHeight(); }; - const handleEditableElementKeyDown = (event: React.KeyboardEvent) => { - fireKeyboardEvent(onKeyDown, event); + const handleEditableElementKeyDown = useCallback( + (event: React.KeyboardEvent) => { + fireKeyboardEvent(onKeyDown, event); - if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { - const form = (event.currentTarget as HTMLElement).closest('form'); - if (form && !event.isDefaultPrevented()) { - form.requestSubmit(); - } - event.preventDefault(); - const plainText = tokensToText ? tokensToText(tokens ?? []) : getPromptText(tokens ?? []); - fireNonCancelableEvent(onAction, { value: plainText, tokens: [...(tokens ?? [])] }); - } + if (keyboardHandlers) { + // Handle menu navigation first + if (keyboardHandlers.handleMenuNavigation(event)) { + return; + } - // Single-backspace deletion for tokens - if (event.key === 'Backspace') { - const selection = window.getSelection(); - if (!selection?.rangeCount || !selection.isCollapsed) { - return; + // Handle Enter key for form submission + keyboardHandlers.handleEnterKey(event); } - const range = selection.getRangeAt(0); - let nodeToCheck = range.startContainer; - - // If at start of text node, check previous sibling - if (nodeToCheck.nodeType === Node.TEXT_NODE && range.startOffset === 0) { - nodeToCheck = nodeToCheck.previousSibling as Node; - } - // If cursor is in the contentEditable itself (not in a text node), check last child - else if (nodeToCheck === editableElementRef.current && range.startOffset > 0) { - nodeToCheck = editableElementRef.current.childNodes[range.startOffset - 1]; - } + if (keyboardHandlers) { + // Handle Backspace for token deletion + if (keyboardHandlers.handleBackspaceKey(event)) { + return; + } - // If we're about to delete an empty text node, check if there's a token before it - if (nodeToCheck?.nodeType === Node.TEXT_NODE && nodeToCheck.textContent === '') { - const previousNode = nodeToCheck.previousSibling; - if (previousNode?.nodeType === Node.ELEMENT_NODE) { - const element = previousNode as Element; - if (element.hasAttribute('data-token-type')) { - // Skip the empty text node and target the token instead - nodeToCheck = previousNode; + // Handle mode token deletion + if (event.key === 'Backspace') { + const selection = window.getSelection(); + if (!selection?.rangeCount || !selection.isCollapsed) { + return; } - } - } - // Check if it's a token element - if (nodeToCheck?.nodeType === Node.ELEMENT_NODE) { - const element = nodeToCheck as Element; - const tokenType = element.getAttribute('data-token-type'); + const range = selection.getRangeAt(0); + let nodeToCheck = range.startContainer; + + if (nodeToCheck.nodeType === Node.TEXT_NODE && range.startOffset === 0) { + nodeToCheck = nodeToCheck.previousSibling as Node; + } else if (nodeToCheck === editableElementRef.current && range.startOffset > 0) { + nodeToCheck = editableElementRef.current.childNodes[range.startOffset - 1]; + } - if (tokenType === 'mode') { - // For mode tokens, fire the callback and let parent handle state - event.preventDefault(); - if (onModeRemoved) { - fireNonCancelableEvent(onModeRemoved, { mode: undefined }); + if (nodeToCheck?.nodeType === Node.TEXT_NODE && nodeToCheck.textContent === '') { + const previousNode = nodeToCheck.previousSibling; + if (previousNode?.nodeType === Node.ELEMENT_NODE) { + const element = previousNode as Element; + if (element.hasAttribute('data-token-type')) { + nodeToCheck = previousNode; + } + } } - } else if (tokenType) { - // For other tokens (like references), remove directly - event.preventDefault(); - element.remove(); - editableElementRef.current?.dispatchEvent(new Event('input', { bubbles: true })); + + keyboardHandlers.handleModeBackspace(event, nodeToCheck); } } - } - }; - const handleEditableElementChange = useCallback>(() => { - if (isRenderingRef.current || !editableElementRef.current) { - return; - } + // EVENT-DRIVEN TRIGGER DETECTION + // Check if the pressed key is a menu trigger character + if (menus && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + const triggerChar = event.key; + const matchingMenu = menus.find(m => m.trigger === triggerChar); + + if (matchingMenu && editableElementRef.current) { + // Get cursor position in DOM space + const cursorManager = createCursorManager(editableElementRef.current); + const domCursorPosition = cursorManager.getPosition(); + + // Convert DOM position to cursor space position + // Walk through tokens and count characters + let cursorSpacePosition = 0; + let domCharCount = 0; - let extractedTokens = domToTokenArray(editableElementRef.current); - - // TEMPORARY: Convert /word to mode tokens and @word to reference tokens - // This is prototype functionality and should be replaced with proper menu system - const processedTokens: PromptInputProps.InputToken[] = []; - let detectedMode: PromptInputProps.InputToken | undefined; - - extractedTokens.forEach(token => { - if (token.type === 'text' && token.value) { - const text = token.value; - // Match /word or @word followed by space - const pattern = /(\/\w+\s|@\w+\s)/g; - let lastIndex = 0; - let match; - let textBuffer = ''; - - while ((match = pattern.exec(text)) !== null) { - // Add any text before the match to buffer - textBuffer += text.substring(lastIndex, match.index); - - // Flush text buffer if not empty - if (textBuffer) { - processedTokens.push({ type: 'text', value: textBuffer }); - textBuffer = ''; + for (const token of virtualTokensRef.current) { + if (token.type === 'text') { + const tokenLength = token.value.length; + if (domCharCount + tokenLength >= domCursorPosition) { + // Cursor is within this text token + cursorSpacePosition += domCursorPosition - domCharCount; + break; + } + domCharCount += tokenLength; + cursorSpacePosition += tokenLength; + } else { + // Non-text tokens: count their label length in DOM but only 1 in cursor space + const domLength = token.label?.length || token.value.length; + if (domCharCount + domLength >= domCursorPosition) { + // Cursor is within or after this token - treat as after the token + cursorSpacePosition += 1; + break; + } + domCharCount += domLength; + cursorSpacePosition += 1; + } } - const matchedText = match[0]; - const trigger = matchedText[0]; // "/" or "@" - const word = matchedText.substring(1).trim(); // Remove trigger and trailing space - - if (trigger === '/' && !mode) { - // Only convert /word to mode token if no mode is already set - detectedMode = { - type: 'mode', - id: word, - label: word, - value: `Some dummy mode system prompt`, - }; - } else if (trigger === '/' && mode) { - // If mode already exists, keep /word as text - textBuffer += matchedText; - } else if (trigger === '@') { - // Convert @word to reference token - processedTokens.push({ - type: 'reference', - id: word, - label: word, - value: `Some dummy details`, - }); + // Build text in cursor space to check position validity + let textInCursorSpace = ''; + for (const token of virtualTokensRef.current) { + if (token.type === 'text') { + textInCursorSpace += token.value; + } else { + textInCursorSpace += '\uFFFC'; + } } - lastIndex = pattern.lastIndex; - } + // Check if trigger is valid at this position + let isValidPosition = false; - // Add any remaining text - textBuffer += text.substring(lastIndex); - if (textBuffer) { - processedTokens.push({ type: 'text', value: textBuffer }); - } - } else { - processedTokens.push(token); - } - }); + if (matchingMenu.useAtStart) { + // Must be at position 0 (start of input) OR position 1 if there's a mode token + // This allows changing the mode by typing a new mode trigger + const hasModeToken = virtualTokensRef.current[0]?.type === 'mode'; + isValidPosition = cursorSpacePosition === 0 || (hasModeToken && cursorSpacePosition === 1); + } else { + // Must be at start or after whitespace + const charBefore = textInCursorSpace[cursorSpacePosition - 1]; + isValidPosition = cursorSpacePosition === 0 || /\s/.test(charBefore || ''); + } - extractedTokens = processedTokens; + if (isValidPosition) { + // Wrap the trigger in a temporary element for easy tracking + // Wait for the trigger character to be inserted first + setTimeout(() => { + if (!editableElementRef.current) { + return; + } - const plainText = tokensToText ? tokensToText(extractedTokens) : getPromptText(extractedTokens); + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } - // Check if mode token was removed from the DOM - if (lastModeRef.current && onModeRemoved) { - const hasModeToken = editableElementRef.current.querySelector('[data-token-type="mode"]'); - if (!hasModeToken) { - // Mode token was removed, notify parent - fireNonCancelableEvent(onModeRemoved, { mode: undefined }); - lastModeRef.current = undefined; - } - } + // Get current cursor position (should be right after the trigger char) + const range = selection.getRangeAt(0); + const cursorNode = range.startContainer; + const cursorOffset = range.startOffset; - // If we detected new tokens (mode or reference), re-render them as Token elements - const hasNewTokens = - detectedMode || - extractedTokens.some( - (token, index) => - token.type === 'reference' && - (!lastTokensRef.current || - !lastTokensRef.current[index] || - lastTokensRef.current[index].type !== 'reference') - ); - - if (hasNewTokens && editableElementRef.current) { - isRenderingRef.current = true; - const allTokens = detectedMode ? [detectedMode, ...extractedTokens] : extractedTokens; - renderTokensToDOM(allTokens, editableElementRef.current, reactContainersRef.current); - isRenderingRef.current = false; - // Place cursor at the end after rendering tokens - setCursorToEnd(editableElementRef.current); - } + // Find the text node containing the trigger + let textNode: Text | null = null; + let triggerOffset = 0; - // Filter out mode tokens from extractedTokens - mode is tracked separately - const tokensWithoutMode = extractedTokens.filter(token => token.type !== 'mode'); + if (cursorNode.nodeType === Node.TEXT_NODE) { + textNode = cursorNode as Text; + // Trigger should be right before cursor + triggerOffset = cursorOffset - 1; + } else if (cursorNode.nodeType === Node.ELEMENT_NODE) { + // Cursor is between elements or at start/end + // Look for the text node at the cursor position + const childNodes = Array.from(cursorNode.childNodes); + const nodeAtCursor = childNodes[cursorOffset - 1]; - lastTokensRef.current = tokensWithoutMode; - fireNonCancelableEvent(onChange, { value: plainText, tokens: tokensWithoutMode, mode: detectedMode }); - adjustInputHeight(); - }, [tokensToText, onModeRemoved, onChange, adjustInputHeight, mode]); + if (nodeAtCursor?.nodeType === Node.TEXT_NODE) { + textNode = nodeAtCursor as Text; + triggerOffset = (textNode.textContent?.length || 0) - 1; + } + } + + if (!textNode || triggerOffset < 0) { + return; + } + + // Create a wrapper span for the trigger region + const wrapper = document.createElement('span'); + wrapper.setAttribute('data-menu-trigger', matchingMenu.id); + wrapper.style.display = 'inline'; + + // Split the text node at the trigger position + const beforeTrigger = textNode.textContent?.substring(0, triggerOffset) || ''; + const triggerAndAfter = textNode.textContent?.substring(triggerOffset) || ''; + + // Find where the trigger region ends (next whitespace or end) + let endOffset = 1; // Start after trigger char + while (endOffset < triggerAndAfter.length && !/\s/.test(triggerAndAfter[endOffset])) { + endOffset++; + } + + const triggerRegion = triggerAndAfter.substring(0, endOffset); + const afterTrigger = triggerAndAfter.substring(endOffset); + + // Replace the text node with: beforeText + wrapper(triggerRegion) + afterText + const parent = textNode.parentNode; + if (!parent) { + return; + } + + const fragment = document.createDocumentFragment(); + + if (beforeTrigger) { + fragment.appendChild(document.createTextNode(beforeTrigger)); + } + + wrapper.textContent = triggerRegion; + fragment.appendChild(wrapper); + + if (afterTrigger) { + fragment.appendChild(document.createTextNode(afterTrigger)); + } + + parent.replaceChild(fragment, textNode); + + // Place cursor at end of wrapper (after trigger char) + const newRange = document.createRange(); + newRange.setStart(wrapper.firstChild || wrapper, 1); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + + // Open menu + setDetectedTrigger({ + menuId: matchingMenu.id, + filterText: '', + triggerPosition: cursorSpacePosition, + }); + }, 0); + } + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [menus, onKeyDown] + ); + + // Store keydown handler in ref for insertText method + useEffect(() => { + keydownHandlerRef.current = handleEditableElementKeyDown; + }, [handleEditableElementKeyDown]); const handleEditableElementBlur = useCallback(() => { - if (isTokenMode) { - savedSelectionRef.current = saveSelection(); - } + // Close menu on blur + setDetectedTrigger(null); + if (onBlur) { fireNonCancelableEvent(onBlur); } - }, [isTokenMode, onBlur]); - - // Render tokens into contentEditable when they change externally - useEffect(() => { - if (isTokenMode && editableElementRef.current) { - // Only re-render if tokens or mode changed from outside (not from user input) - if (tokens !== lastTokensRef.current || mode !== lastModeRef.current) { - isRenderingRef.current = true; - // Combine mode token (if exists) with regular tokens - const allTokens = mode ? [mode, ...(tokens ?? [])] : (tokens ?? []); - renderTokensToDOM(allTokens, editableElementRef.current, reactContainersRef.current); - isRenderingRef.current = false; - lastTokensRef.current = tokens; - lastModeRef.current = mode; - setCursorToEnd(editableElementRef.current); - } - } - adjustInputHeight(); - }, [isTokenMode, tokens, mode, adjustInputHeight]); + }, [onBlur]); // Handle window resize useEffect(() => { @@ -410,11 +669,6 @@ const InternalPromptInput = React.forwardRef( return () => window.removeEventListener('resize', handleResize); }, [adjustInputHeight]); - // Track mode changes - useEffect(() => { - lastModeRef.current = mode; - }, [mode]); - // Auto-focus on mount useEffect(() => { if (isTokenMode && autoFocus && editableElementRef.current) { @@ -432,15 +686,317 @@ const InternalPromptInput = React.forwardRef( }; }, []); - // Future: Implement menu trigger detection and dropdown rendering - // When menus are defined, detect trigger characters and show appropriate menu - // Use onMenuSelect to handle option selection - // Use onMenuLoadItems for async menu data loading - // Use menuErrorIconAriaLabel for accessibility in error states - void menus; - void onMenuSelect; - void onMenuLoadItems; - void menuErrorIconAriaLabel; + // Handle menu option selection - update tokens and let reactive system handle DOM/cursor + const handleMenuSelect = useCallback( + (option: MenuItem) => { + if (!activeMenu || !detectedTrigger || !editableElementRef.current) { + return; + } + + // Find and remove the trigger wrapper element + const wrapper = editableElementRef.current.querySelector( + `[data-menu-trigger="${detectedTrigger.menuId}"]` + ) as HTMLElement; + + if (wrapper) { + // Remove the wrapper, leaving its content will be handled by token update + wrapper.remove(); + } + + const triggerLength = 1 + detectedTrigger.filterText.length; // trigger char + filter text + const isMode = activeMenu.useAtStart; + + // Use the memoized virtualTokens - single source of truth + let newVirtualTokens: VirtualToken[]; + let cursorPosition: number; + + if (isMode) { + // Mode: add mode token and remove trigger text from first text token + const modeToken: VirtualToken = { + type: 'mode', + id: option.option.value || '', + label: option.option.label || option.option.value || '', + value: option.option.value || '', + }; + + // Check if there's already a mode token + const existingModeIndex = virtualTokens.findIndex(t => t.type === 'mode'); + const hasExistingMode = existingModeIndex !== -1; + + const firstTextIndex = virtualTokens.findIndex(t => t.type === 'text'); + + if (firstTextIndex >= 0) { + const firstToken = virtualTokens[firstTextIndex]; + const afterTrigger = firstToken.value.substring(triggerLength); + + if (hasExistingMode) { + // Replace existing mode token + newVirtualTokens = [ + modeToken, + ...(afterTrigger ? [{ type: 'text' as const, value: afterTrigger }] : []), + ...virtualTokens.slice(firstTextIndex + 1), + ]; + } else { + // Insert new mode token + newVirtualTokens = [ + modeToken, + ...virtualTokens.slice(0, firstTextIndex), + ...(afterTrigger ? [{ type: 'text' as const, value: afterTrigger }] : []), + ...virtualTokens.slice(firstTextIndex + 1), + ]; + } + } else { + // No text tokens, just replace or add mode token + newVirtualTokens = [modeToken, ...virtualTokens.filter(t => t.type !== 'mode')]; + } + + // Cursor after mode token = 1 + cursorPosition = 1; + } else { + // Reference: remove trigger and insert reference token + const newToken: VirtualToken = { + type: 'reference', + id: option.option.value || '', + label: option.option.label || option.option.value || '', + value: option.option.value || '', + }; + + // Find token containing trigger position (in cursor space) + let currentPos = 0; + let insertIndex = -1; + let insertOffset = 0; + + for (let i = 0; i < virtualTokens.length; i++) { + const token = virtualTokens[i]; + const tokenLength = token.type === 'text' ? token.value.length : 1; + + if (currentPos <= menuTriggerPosition && currentPos + tokenLength > menuTriggerPosition) { + insertIndex = i; + insertOffset = menuTriggerPosition - currentPos; + break; + } + + currentPos += tokenLength; + } + + newVirtualTokens = []; + + if (insertIndex === -1) { + // Trigger not found, append at end + newVirtualTokens.push(...virtualTokens, newToken, { type: 'text', value: ' ' }); + } else { + // Insert at trigger position + for (let i = 0; i < virtualTokens.length; i++) { + if (i < insertIndex) { + newVirtualTokens.push(virtualTokens[i]); + } else if (i === insertIndex) { + const token = virtualTokens[i]; + if (token.type === 'text') { + const beforeTrigger = token.value.substring(0, insertOffset); + const afterTrigger = token.value.substring(insertOffset + triggerLength); + + if (beforeTrigger) { + newVirtualTokens.push({ type: 'text', value: beforeTrigger }); + } + newVirtualTokens.push(newToken); + if (afterTrigger) { + newVirtualTokens.push({ type: 'text', value: afterTrigger }); + } else { + newVirtualTokens.push({ type: 'text', value: ' ' }); + } + } + } else { + newVirtualTokens.push(virtualTokens[i]); + } + } + } + + // Cursor: trigger position + 1 (reference token) + 1 (trailing space) + cursorPosition = menuTriggerPosition + 1 + 1; + } + + // DON'T update virtualTokensRef here - let it update from props + // This ensures cursor conversion uses the correct token array + + // Split virtualTokens back into mode and tokens + const newTokens: PromptInputProps.InputToken[] = newVirtualTokens + .filter(t => t.type !== 'mode') + .map(t => { + if (t.type === 'text') { + return { type: 'text', value: t.value }; + } else { + return { type: 'reference', id: t.id!, label: t.label!, value: t.value }; + } + }); + + // Update tokens via onChange - reactive system will handle DOM and cursor + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + fireNonCancelableEvent(onChange, { + value, + tokens: newTokens, + }); + + // Notify parent about the selection + fireNonCancelableEvent(onMenuItemSelect, { + menuId: activeMenu.id, + option: option.option, + }); + + // Set desired cursor position for reactive update + setDesiredCursorPosition(cursorPosition); + + // Clear trigger - menu will close automatically + setDetectedTrigger(null); + }, + [activeMenu, detectedTrigger, tokensToText, onChange, onMenuItemSelect, virtualTokens, menuTriggerPosition] + ); + + // Menu items controller - always call hooks + const menuItemsResult = useMenuItems({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + filterText: menuFilterText, + onSelectItem: handleMenuSelect, + }); + + // Keep menu items state stable to prevent dropdown from unmounting during state updates + const [menuItemsState, menuItemsHandlers] = menuItemsResult; + const stableMenuItemsState = activeMenu ? menuItemsState : null; + const stableMenuItemsHandlers = activeMenu ? menuItemsHandlers : null; + + // Handle token deletion at cursor + const handleDeleteTokenAtCursor = useCallback(() => { + if (!editableElementRef.current || !tokens) { + return false; + } + + const cursorManager = createCursorManager(editableElementRef.current); + const cursorPosition = cursorManager.getPosition(); + + let currentPos = 0; + let tokenIndexToDelete = -1; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const tokenLength = token.type === 'text' ? token.value.length : 1; + + if (cursorPosition > currentPos && cursorPosition <= currentPos + tokenLength) { + if (token.type !== 'text') { + tokenIndexToDelete = i; + break; + } + } else if (cursorPosition === currentPos && token.type !== 'text') { + tokenIndexToDelete = i; + break; + } + + currentPos += tokenLength; + } + + if (tokenIndexToDelete >= 0) { + const newTokens = [...tokens]; + newTokens.splice(tokenIndexToDelete, 1); + + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + fireNonCancelableEvent(onChange, { + value, + tokens: newTokens, + }); + + return true; + } + + return false; + }, [tokens, tokensToText, onChange]); + + // Create keyboard handlers + const keyboardHandlers = useMemo(() => { + if (!editableElementRef.current) { + return null; + } + + return createKeyboardHandlers({ + menuOpen: menuIsOpen, + menuItemsState: stableMenuItemsState, + menuItemsHandlers: stableMenuItemsHandlers, + onAction: onAction ? detail => fireNonCancelableEvent(onAction, detail) : undefined, + onModeRemoved: onModeRemoved ? () => fireNonCancelableEvent(onModeRemoved) : undefined, + tokensToText, + tokens, + getPromptText, + deleteTokenAtCursor: handleDeleteTokenAtCursor, + closeMenu: () => { + setDetectedTrigger(null); + }, + }); + }, [ + menuIsOpen, + stableMenuItemsState, + stableMenuItemsHandlers, + onAction, + onModeRemoved, + tokensToText, + tokens, + handleDeleteTokenAtCursor, + ]); + + // Menu load more controller - always call hooks + const menuLoadMoreResult = useMenuLoadMore({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + statusType: activeMenu?.statusType ?? 'finished', + onLoadItems: detail => { + fireNonCancelableEvent(onMenuLoadItems, detail); + }, + onLoadMoreItems: () => { + fireNonCancelableEvent(onMenuLoadMoreItems, { + menuId: activeMenu?.id ?? '', + }); + }, + }); + + const menuLoadMoreHandlers = activeMenu ? menuLoadMoreResult : null; + + // Fire load items when menu opens + useEffect(() => { + if (menuIsOpen && activeMenu && menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnMenuOpen(); + } + }, [menuIsOpen, activeMenu, menuLoadMoreHandlers]); + + // Update filter text on cursor movement or content change when menu is open + useEffect(() => { + if (!isTokenMode || !editableElementRef.current || !detectedTrigger) { + return; + } + + const handleSelectionChange = () => { + updateMenuFilterText(); + }; + + // Also update on any DOM mutations (content changes) + const observer = new MutationObserver(() => { + updateMenuFilterText(); + }); + + observer.observe(editableElementRef.current, { + childList: true, + characterData: true, + subtree: true, + }); + + document.addEventListener('selectionchange', handleSelectionChange); + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + observer.disconnect(); + }; + }, [isTokenMode, detectedTrigger, updateMenuFilterText]); const hasActionButton = !!( actionButtonIconName || @@ -449,11 +1005,8 @@ const InternalPromptInput = React.forwardRef( customPrimaryAction ); - const showPlaceholder = - isTokenMode && - placeholder && - !mode && - (!tokens || tokens.length === 0 || (tokens.length === 1 && tokens[0].type === 'text' && !tokens[0].value)); + // Show placeholder in token mode when input is empty (no mode, no tokens with content) + const showPlaceholder = isTokenMode && placeholder && !mode && (!tokens || tokens.length === 0); const textareaAttributes: React.TextareaHTMLAttributes = { 'aria-label': ariaLabel, @@ -504,17 +1057,47 @@ const InternalPromptInput = React.forwardRef( spellCheck: spellcheck, onKeyDown: handleEditableElementKeyDown, onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - onInput: handleEditableElementChange, onBlur: handleEditableElementBlur, onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; + // Menu dropdown setup + const menuListId = useUniqueId('menu-list'); + const menuFooterControlId = useUniqueId('menu-footer'); + const highlightedMenuOptionIdSource = useUniqueId(); + const highlightedMenuOptionId = stableMenuItemsState?.highlightedOption ? highlightedMenuOptionIdSource : undefined; + + // Always call useDropdownStatus hook + const menuDropdownStatusResult = useDropdownStatus({ + ...(activeMenu ?? {}), + isEmpty: !stableMenuItemsState || stableMenuItemsState.items.length === 0, + recoveryText: i18nStrings?.menuRecoveryText, + errorIconAriaLabel: i18nStrings?.menuErrorIconAriaLabel, + onRecoveryClick: () => { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnRecoveryClick(); + } + editableElementRef.current?.focus(); + }, + hasRecoveryCallback: Boolean(onMenuLoadItems), + }); + + const menuDropdownStatus = activeMenu ? menuDropdownStatusResult : null; + + // Keep dropdown open while menu is active, even with 0 filtered results + // This prevents flickering during filtering + // Use stableMenuItemsState to prevent unmounting during state transitions + const shouldRenderMenuDropdown = useMemo( + () => menuIsOpen && activeMenu && stableMenuItemsState, + [menuIsOpen, activeMenu, stableMenuItemsState] + ); + const actionButton = (
{customPrimaryAction ?? ( { - const plainText = isTokenMode - ? tokensToText - ? tokensToText(tokens ?? []) - : getPromptText(tokens ?? []) - : value; - fireNonCancelableEvent(onAction, { value: plainText, tokens: [...(tokens ?? [])] }); + fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); }} variant="icon" /> @@ -564,21 +1142,70 @@ const InternalPromptInput = React.forwardRef(
{isTokenMode ? ( <> - {name && ( - - )} -
+ {name && } +
+ { + event.preventDefault(); + }} + trigger={ +
+ } + footer={ + menuDropdownStatus?.isSticky && menuDropdownStatus.content ? ( + = 1 : false} + /> + ) : null + } + > + {shouldRenderMenuDropdown && stableMenuItemsState && stableMenuItemsHandlers && activeMenu && ( + { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnScroll(); + } + }} + hasDropdownStatus={menuDropdownStatus?.content !== null} + selectedMenuItemAriaLabel={i18nStrings?.selectedMenuItemAriaLabel} + renderHighlightedMenuItemAriaLive={rest.renderHighlightedMenuItemAriaLive} + listBottom={ + !menuDropdownStatus?.isSticky ? ( + + ) : null + } + ariaDescribedby={menuDropdownStatus?.content ? menuFooterControlId : undefined} + /> + )} + +
) : ( void; +} + +export interface MenuItemsState extends HighlightedOptionState { + items: readonly MenuItem[]; + showAll: boolean; + getItemGroup: (item: MenuItem) => undefined | OptionGroup; +} + +export interface MenuItemsHandlers extends HighlightedOptionHandlers { + selectHighlightedOptionWithKeyboard(): boolean; + highlightVisibleOptionWithMouse(index: number): void; + selectVisibleOptionWithMouse(index: number): void; +} + +const isHighlightable = (option?: MenuItem) => { + return !!option && option.type !== 'parent'; +}; + +const isInteractive = (option?: MenuItem) => !!option && !option.disabled && option.type !== 'parent'; + +export const useMenuItems = ({ + menu, + filterText, + onSelectItem, +}: UseMenuItemsProps): [MenuItemsState, MenuItemsHandlers] => { + const { items, getItemGroup, getItemParent } = useMemo(() => createItems(menu.options), [menu.options]); + + const filteredItems = useMemo(() => { + const filteringType = menu.filteringType ?? 'auto'; + const filtered: MenuItem[] = + filteringType === 'auto' ? (filterOptions(items, filterText) as MenuItem[]) : [...items]; + generateTestIndexes(filtered, getItemParent); + return filtered; + }, [menu.filteringType, items, filterText, getItemParent]); + + const [highlightedOptionState, highlightedOptionHandlers] = useHighlightedOption({ + options: filteredItems, + isHighlightable, + }); + + const selectHighlightedOptionWithKeyboard = () => { + const { highlightedOption } = highlightedOptionState; + if (!highlightedOption || !isInteractive(highlightedOption)) { + return false; + } + onSelectItem(highlightedOption); + return true; + }; + + const highlightVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isHighlightable(item)) { + highlightedOptionHandlers.setHighlightedIndexWithMouse(index); + } + }; + + const selectVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isInteractive(item)) { + onSelectItem(item); + } + }; + + return [ + { ...highlightedOptionState, items: filteredItems, showAll: false, getItemGroup }, + { + ...highlightedOptionHandlers, + selectHighlightedOptionWithKeyboard, + highlightVisibleOptionWithMouse, + selectVisibleOptionWithMouse, + }, + ]; +}; + +function createItems(options: readonly OptionDefinition[]) { + const items: MenuItem[] = []; + const itemToGroup = new WeakMap(); + const getItemParent = (item: MenuItem) => itemToGroup.get(item); + const getItemGroup = (item: MenuItem) => getItemParent(item)?.option as OptionGroup; + + for (const option of options) { + if (isGroup(option)) { + for (const item of flattenGroup(option)) { + items.push(item); + } + } else { + items.push({ ...option, option }); + } + } + + function flattenGroup(group: OptionGroup) { + const { options, ...rest } = group; + + let hasOnlyDisabledChildren = true; + + const groupItem: MenuItem = { ...rest, type: 'parent', option: group }; + + const items: MenuItem[] = [groupItem]; + + for (const option of options) { + if (!option.disabled) { + hasOnlyDisabledChildren = false; + } + + const childOption: MenuItem = { + ...option, + type: 'child', + disabled: option.disabled || rest.disabled, + option, + }; + + items.push(childOption); + + itemToGroup.set(childOption, groupItem); + } + + items[0].disabled = items[0].disabled || hasOnlyDisabledChildren; + + return items; + } + + return { items, getItemGroup, getItemParent }; +} + +function isGroup(optionOrGroup: OptionDefinition): optionOrGroup is OptionGroup { + return 'options' in optionOrGroup; +} diff --git a/src/prompt-input/menus/menu-load-more-controller.ts b/src/prompt-input/menus/menu-load-more-controller.ts new file mode 100644 index 0000000000..b9cfdb9257 --- /dev/null +++ b/src/prompt-input/menus/menu-load-more-controller.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useRef } from 'react'; + +import { DropdownStatusProps } from '../../internal/components/dropdown-status/interfaces'; +import { PromptInputProps } from '../interfaces'; + +interface UseMenuLoadMoreProps { + menu: PromptInputProps.MenuDefinition; + statusType: DropdownStatusProps.StatusType; + onLoadItems: (detail: PromptInputProps.MenuLoadItemsDetail) => void; + onLoadMoreItems?: () => void; +} + +interface MenuLoadMoreHandlers { + fireLoadMoreOnScroll(): void; + fireLoadMoreOnRecoveryClick(): void; + fireLoadMoreOnMenuOpen(): void; + fireLoadMoreOnInputChange(filteringText: string): void; +} + +export const useMenuLoadMore = ({ + menu, + statusType, + onLoadItems, + onLoadMoreItems, +}: UseMenuLoadMoreProps): MenuLoadMoreHandlers => { + const lastFilteringText = useRef(null); + + const fireLoadMore = (firstPage: boolean, samePage: boolean, filteringText?: string) => { + if (filteringText !== undefined && filteringText !== lastFilteringText.current) { + lastFilteringText.current = filteringText; + } + + if (filteringText === undefined || lastFilteringText.current !== filteringText) { + onLoadItems({ + menuId: menu.id, + filteringText: lastFilteringText.current ?? '', + firstPage, + samePage, + }); + } + }; + + const fireLoadMoreOnScroll = () => { + if (menu.options.length > 0 && statusType === 'pending') { + onLoadMoreItems ? onLoadMoreItems() : fireLoadMore(false, false); + } + }; + + const fireLoadMoreOnRecoveryClick = () => fireLoadMore(false, true); + + const fireLoadMoreOnMenuOpen = () => fireLoadMore(true, false, lastFilteringText.current ?? ''); + + const fireLoadMoreOnInputChange = (filteringText: string) => fireLoadMore(true, false, filteringText); + + return { fireLoadMoreOnScroll, fireLoadMoreOnRecoveryClick, fireLoadMoreOnMenuOpen, fireLoadMoreOnInputChange }; +}; diff --git a/src/prompt-input/menus/menu-options-list.tsx b/src/prompt-input/menus/menu-options-list.tsx new file mode 100644 index 0000000000..d01b23c6f2 --- /dev/null +++ b/src/prompt-input/menus/menu-options-list.tsx @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import PlainList from '../../autosuggest/plain-list'; +import VirtualList from '../../autosuggest/virtual-list'; +import { useAnnouncement } from '../../select/utils/use-announcement'; +import { PromptInputProps } from '../interfaces'; +import { MenuItemsHandlers, MenuItemsState } from './menu-controller'; + +interface MenuOptionsListProps { + menu: PromptInputProps.MenuDefinition; + statusType: PromptInputProps.MenuDefinition['statusType']; + menuItemsState: MenuItemsState; + menuItemsHandlers: MenuItemsHandlers; + highlightedOptionId?: string; + highlightText: string; + listId: string; + controlId: string; + handleLoadMore: () => void; + hasDropdownStatus?: boolean; + listBottom?: React.ReactNode; + ariaDescribedby?: string; + selectedMenuItemAriaLabel?: string; + renderHighlightedMenuItemAriaLive?: PromptInputProps['renderHighlightedMenuItemAriaLive']; +} + +const createMouseEventHandler = (handler: (index: number) => void) => (itemIndex: number) => { + // prevent mouse events to avoid losing focus from the input + if (itemIndex > -1) { + handler(itemIndex); + } +}; + +export default function MenuOptionsList({ + menu, + statusType, + menuItemsState, + menuItemsHandlers, + highlightedOptionId, + highlightText, + listId, + controlId, + handleLoadMore, + hasDropdownStatus, + listBottom, + ariaDescribedby, + selectedMenuItemAriaLabel, + renderHighlightedMenuItemAriaLive, +}: MenuOptionsListProps) { + const handleMouseUp = createMouseEventHandler(menuItemsHandlers.selectVisibleOptionWithMouse); + const handleMouseMove = createMouseEventHandler(menuItemsHandlers.highlightVisibleOptionWithMouse); + + const ListComponent = menu.virtualScroll ? VirtualList : PlainList; + + const announcement = useAnnouncement({ + highlightText, + announceSelected: false, + highlightedOption: menuItemsState.highlightedOption, + getParent: option => menuItemsState.getItemGroup(option), + selectedAriaLabel: selectedMenuItemAriaLabel, + renderHighlightedAriaLive: renderHighlightedMenuItemAriaLive, + }); + + return ( + + ); +} diff --git a/src/prompt-input/selection-utils.ts b/src/prompt-input/selection-utils.ts deleted file mode 100644 index 050f1bc150..0000000000 --- a/src/prompt-input/selection-utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Finds the DOM node and offset for a given character position in contentEditable. - * Reference tokens count as 1 character each. - */ -export function findNodeAndOffset(element: HTMLElement, targetOffset: number): { node: Node; offset: number } | null { - let currentOffset = 0; - - for (const child of Array.from(element.childNodes)) { - // Reference token element - counts as 1 character - if ( - child.nodeType === Node.ELEMENT_NODE && - (child as HTMLElement).getAttribute('data-token-type') === 'reference' - ) { - if (currentOffset === targetOffset) { - return { node: child, offset: 0 }; - } - currentOffset += 1; - continue; - } - - // Text node - if (child.nodeType === Node.TEXT_NODE) { - const textLength = child.textContent?.length || 0; - if (currentOffset + textLength >= targetOffset) { - return { node: child, offset: targetOffset - currentOffset }; - } - currentOffset += textLength; - } - } - - // Return last position if not found - const lastChild = element.lastChild; - return lastChild ? { node: lastChild, offset: lastChild.textContent?.length || 0 } : null; -} - -/** - * Sets the cursor position at the end of contentEditable element. - */ -export function setCursorToEnd(element: HTMLElement): void { - requestAnimationFrame(() => { - const range = document.createRange(); - const selection = window.getSelection(); - range.selectNodeContents(element); - range.collapse(false); - selection?.removeAllRanges(); - selection?.addRange(range); - }); -} - -/** - * Saves the current selection range. - */ -export function saveSelection(): Range | null { - const selection = window.getSelection(); - return selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; -} - -/** - * Restores a previously saved selection range. - */ -export function restoreSelection(range: Range | null): void { - if (range) { - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range.cloneRange()); - } -} diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index 2c50d03532..d38f95996b 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -220,6 +220,11 @@ $invalid-border-offset: constants.$invalid-control-left-padding; } } +.editable-wrapper { + flex: 1; + min-inline-size: 0; +} + .primary-action { align-self: flex-end; flex-shrink: 0; @@ -286,3 +291,8 @@ $invalid-border-offset: constants.$invalid-control-left-padding; align-self: stretch; cursor: text; } + +// Mode token spacing +.mode-token { + margin-inline-end: awsui.$space-xxs; +} diff --git a/src/prompt-input/test-classes/styles.scss b/src/prompt-input/test-classes/styles.scss index a395897823..f783c7ddc2 100644 --- a/src/prompt-input/test-classes/styles.scss +++ b/src/prompt-input/test-classes/styles.scss @@ -10,6 +10,10 @@ /* used in test-utils */ } +.content-editable { + /* used in test-utils - contentEditable element for token mode */ +} + .action-button { /* used in test-utils */ } diff --git a/src/prompt-input/token-utils.tsx b/src/prompt-input/tokens/token-utils.tsx similarity index 70% rename from src/prompt-input/token-utils.tsx rename to src/prompt-input/tokens/token-utils.tsx index 048ed32c11..c0d860c4ef 100644 --- a/src/prompt-input/token-utils.tsx +++ b/src/prompt-input/tokens/token-utils.tsx @@ -3,17 +3,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import Token from '../token/internal'; -import { PromptInputProps } from './interfaces'; +import Token from '../../token/internal'; +import { PromptInputProps } from '../interfaces'; + +import styles from '../styles.css.js'; const TOKEN_DATA_PREFIX = 'data-token-'; const TOKEN_TYPE_ATTRIBUTE = `${TOKEN_DATA_PREFIX}type`; /** * Creates a DOM element for a token with data attributes. - * @param type - The token type identifier - * @param attributes - Key-value pairs to be set as data-token-* attributes - * @returns A configured span element ready for token rendering */ function createTokenContainerElement(type: string, attributes: Record): HTMLElement { const container = document.createElement('span'); @@ -43,19 +42,8 @@ const tokenRenderers: Record< reference: (token, target, containers) => { if (token.type === 'reference') { const container = createTokenContainerElement('reference', { - id: token.id || '', - value: token.value || '', - }); - target.appendChild(container); - containers.add(container); - ReactDOM.render(, container); - } - }, - mode: (token, target, containers) => { - if (token.type === 'mode') { - const container = createTokenContainerElement('mode', { - id: token.id || '', - value: token.value || '', + id: token.id, + value: token.value, }); target.appendChild(container); containers.add(container); @@ -64,10 +52,24 @@ const tokenRenderers: Record< }, }; +/** + * Renders a mode token into a DOM element. + */ +function renderModeToken(mode: PromptInputProps.ModeToken, target: HTMLElement, containers: Set): void { + const container = createTokenContainerElement('mode', { + id: mode.id, + value: mode.value, + }); + target.appendChild(container); + containers.add(container); + ReactDOM.render( + , + container + ); +} + /** * Cleans up React components and DOM content from the target element. - * @param targetElement - The element to clean - * @param reactContainers - Set of React container elements to unmount */ function cleanupDOM(targetElement: HTMLElement, reactContainers: Set): void { reactContainers.forEach(container => { @@ -84,7 +86,6 @@ function cleanupDOM(targetElement: HTMLElement, reactContainers: Set ): void { @@ -111,6 +110,12 @@ export function renderTokensToDOM( cleanupDOM(targetElement, reactContainers); + // Render mode token first if present + if (mode) { + renderModeToken(mode, targetElement, reactContainers); + } + + // Render regular tokens tokens.forEach(token => { const renderer = tokenRenderers[token.type]; if (renderer) { @@ -125,8 +130,6 @@ export function renderTokensToDOM( /** * Extracts all data-token-* attributes from an element. - * @param element - The element to extract attributes from - * @returns Object mapping attribute keys to values (without the data-token- prefix) */ function extractTokenData(element: HTMLElement): Record { return Array.from(element.attributes) @@ -158,24 +161,11 @@ const tokenExtractors: Record< value: data.value || element.textContent || '', }; }, - mode: (element, flushText) => { - flushText(); - const data = extractTokenData(element); - return { - type: 'mode', - id: data.id || '', - label: element.textContent || '', - value: data.value || '', - }; - }, }; /** * Extracts an array of tokens from a contentEditable DOM element. * Converts text nodes to TextInputToken and token elements to their respective types. - * @param element - The contentEditable element to extract tokens from - * @returns Array of extracted tokens - * @throws {Error} If element is not a valid HTMLElement */ export function domToTokenArray(element: HTMLElement): PromptInputProps.InputToken[] { if (!element || !(element instanceof HTMLElement)) { @@ -199,7 +189,10 @@ export function domToTokenArray(element: HTMLElement): PromptInputProps.InputTok const el = node as HTMLElement; const tokenType = el.getAttribute(TOKEN_TYPE_ATTRIBUTE); - if (tokenType && tokenExtractors[tokenType]) { + if (tokenType === 'mode') { + // Skip mode tokens - they're handled separately via the mode prop + flushTextBuffer(); + } else if (tokenType && tokenExtractors[tokenType]) { const token = tokenExtractors[tokenType](el, flushTextBuffer); if (token) { tokens.push(token); @@ -217,22 +210,6 @@ export function domToTokenArray(element: HTMLElement): PromptInputProps.InputTok return tokens; } -export function getPromptText(tokens: readonly PromptInputProps.InputToken[], labelsOnly = false): string { - if (tokens.length === 0) { - return ''; - } - - return tokens - .map(token => { - if (token.type === 'text') { - return token.value || ''; - } else if (token.type === 'reference') { - return labelsOnly ? token.label || '' : token.value || ''; - } else if (token.type === 'mode') { - // Mode tokens don't contribute to the text value - return ''; - } - return ''; - }) - .join(''); +export function getPromptText(tokens: readonly PromptInputProps.InputToken[]): string { + return tokens.map(token => token.value).join(''); } diff --git a/src/prompt-input/tokens/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts new file mode 100644 index 0000000000..3b8c197132 --- /dev/null +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -0,0 +1,222 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { PromptInputProps } from '../interfaces'; +import { createCursorManager } from '../utils/cursor-utils'; +import { domToTokenArray, getPromptText, renderTokensToDOM } from './token-utils'; + +interface UseEditableOptions { + elementRef: React.RefObject; + reactContainersRef: React.MutableRefObject>; + tokens?: readonly PromptInputProps.InputToken[]; + mode?: PromptInputProps.ModeToken; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + onModeRemoved?: () => void; + adjustInputHeight: () => void; + disabled?: boolean; + // Optional cursor position to set when tokens change + cursorPosition?: number | null; +} + +// Helper to compare token arrays for equality +function tokensEqual( + a: readonly PromptInputProps.InputToken[] | undefined, + b: readonly PromptInputProps.InputToken[] | undefined +): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + const tokenA = a[i]; + const tokenB = b[i]; + + if (tokenA.type !== tokenB.type || tokenA.value !== tokenB.value) { + return false; + } + + if (tokenA.type === 'reference' && tokenB.type === 'reference') { + if (tokenA.id !== tokenB.id || tokenA.label !== tokenB.label) { + return false; + } + } + } + + return true; +} + +interface UseEditableReturn { + // Input event handler + handleInput: () => void; +} + +/** + * Custom hook for managing contentEditable elements with token support. + * Follows the use-editable package pattern - focuses on DOM synchronization + * and cursor management while leaving state management to the parent component. + */ +export function useEditableTokens({ + elementRef, + reactContainersRef, + tokens, + mode, + tokensToText, + onChange, + onModeRemoved, + adjustInputHeight, + disabled = false, + cursorPosition = null, +}: UseEditableOptions): UseEditableReturn { + const lastRenderedTokensRef = useRef(undefined); + const lastCursorPositionRef = useRef(null); + + // Create cursor manager instance + const cursorManager = useMemo( + () => (elementRef.current ? createCursorManager(elementRef.current) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [elementRef.current] + ); + + // Cursor position utilities using the cursor manager + const getCursorPosition = useCallback((): number => { + if (!cursorManager) { + return 0; + } + return cursorManager.getPosition(); + }, [cursorManager]); + + const setCursorPosition = useCallback( + (position: number) => { + if (disabled || !cursorManager) { + return; + } + cursorManager.setPosition(position); + }, + [disabled, cursorManager] + ); + + // Handle input events directly - simpler than MutationObserver + const handleInput = useCallback(() => { + if (!elementRef.current) { + return; + } + + // Extract tokens from DOM + const extractedTokens = domToTokenArray(elementRef.current); + + // Check if mode element still exists in DOM + const modeElement = elementRef.current.querySelector('[data-token-type="mode"]'); + const currentMode = modeElement ? true : false; + + // Check if mode was removed + if (mode && !currentMode && onModeRemoved) { + onModeRemoved(); + } + + // Notify parent component of changes + const value = tokensToText ? tokensToText(extractedTokens) : getPromptText(extractedTokens); + onChange({ + value, + tokens: extractedTokens, + }); + + // Update last rendered tokens + lastRenderedTokensRef.current = extractedTokens; + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, onModeRemoved, onChange, adjustInputHeight, tokensToText]); + + // Sync React props to DOM (like a controlled component) + // This is the ONLY place that updates the DOM - tokens prop is the source of truth + const lastRenderedModeRef = useRef(undefined); + + useEffect(() => { + if (disabled || !elementRef.current) { + return; + } + + // Only update DOM if tokens actually changed (avoid re-rendering on every state update) + // Use deep comparison to avoid rebuilding DOM when tokens content is the same + const tokensChanged = !tokensEqual(lastRenderedTokensRef.current, tokens); + + // Check if mode changed + const modeChanged = lastRenderedModeRef.current !== mode; + + // Only consider cursor changed if it's explicitly set (not null) + // null means "preserve current cursor" which shouldn't trigger DOM rebuild + const explicitCursorChange = cursorPosition !== null && lastCursorPositionRef.current !== cursorPosition; + + // Skip DOM rebuild if nothing changed + if (!tokensChanged && !modeChanged && !explicitCursorChange) { + // Still update refs even when skipping rebuild + lastRenderedTokensRef.current = tokens; + lastRenderedModeRef.current = mode; + lastCursorPositionRef.current = cursorPosition; + return; + } + + // Update refs before rebuilding + lastRenderedTokensRef.current = tokens; + lastRenderedModeRef.current = mode; + lastCursorPositionRef.current = cursorPosition; + + // Save current cursor position BEFORE any DOM changes (for normal typing) + const savedCursorPosition = getCursorPosition(); + + // Render tokens to DOM - this clears and rebuilds the entire DOM + const hasContent = mode || (tokens && tokens.length > 0); + if (hasContent) { + renderTokensToDOM(tokens ?? [], mode, elementRef.current, reactContainersRef.current); + } else { + // Clear DOM if no tokens + elementRef.current.innerHTML = ''; + } + + // Restore cursor position after DOM update + requestAnimationFrame(() => { + if (!elementRef.current || !hasContent) { + return; + } + + // Use explicit cursor position if provided (from menu selection, etc.) + // Otherwise restore the saved position (from normal typing) + const positionToSet = cursorPosition !== null ? cursorPosition : savedCursorPosition; + + // Focus BEFORE setting cursor position + elementRef.current.focus(); + + // Try to set cursor position + try { + setCursorPosition(positionToSet); + } catch { + // If cursor positioning fails (e.g., position beyond text nodes), + // fall back to positioning at the end + const range = document.createRange(); + range.selectNodeContents(elementRef.current); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }); + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disabled, tokens, mode, cursorPosition, adjustInputHeight]); + + return { + handleInput, + }; +} + +// Export the interface for the cursor position callback +export type SetCursorPositionCallback = (position: number | null) => void; diff --git a/src/prompt-input/utils/cursor-utils.ts b/src/prompt-input/utils/cursor-utils.ts new file mode 100644 index 0000000000..7b513fe588 --- /dev/null +++ b/src/prompt-input/utils/cursor-utils.ts @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Utility class for managing cursor position and selection in contentEditable elements. + * Handles the complexity of DOM tree walking and range manipulation. + */ +export class CursorManager { + constructor(private element: HTMLElement) {} + + /** + * Gets the current cursor position as a character offset from the start of the element. + */ + getPosition(): number { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return 0; + } + + const range = selection.getRangeAt(0); + const untilRange = document.createRange(); + untilRange.setStart(this.element, 0); + untilRange.setEnd(range.startContainer, range.startOffset); + + return untilRange.toString().length; + } + + /** + * Sets the cursor position to a specific character offset. + */ + setPosition(position: number): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const location = this.walkToPosition(position); + + if (!location) { + // Position is beyond content, set cursor at end + const range = document.createRange(); + range.selectNodeContents(this.element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + return; + } + + const range = document.createRange(); + range.setStart(location.node, location.offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + + /** + * Sets a selection range from start to end positions. + */ + setRange(start: number, end: number): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const startLocation = this.walkToPosition(start); + const endLocation = this.walkToPosition(end); + + if (!startLocation || !endLocation) { + return; + } + + const range = document.createRange(); + range.setStart(startLocation.node, startLocation.offset); + range.setEnd(endLocation.node, endLocation.offset); + selection.removeAllRanges(); + selection.addRange(range); + } + + /** + * Walks the DOM tree to find the node and offset for a given character position. + * Only counts text nodes that are NOT inside contentEditable=false elements. + */ + private walkToPosition(position: number): { node: Node; offset: number } | null { + const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT, { + acceptNode: (node: Node) => { + // Check if this text node is inside a contentEditable=false element + let parent = node.parentElement; + while (parent && parent !== this.element) { + if (parent.contentEditable === 'false') { + return NodeFilter.FILTER_REJECT; + } + parent = parent.parentElement; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let currentPos = 0; + let node: Node | null; + + while ((node = walker.nextNode())) { + const textLength = node.textContent?.length || 0; + if (currentPos + textLength >= position) { + const offset = position - currentPos; + return { node, offset: Math.min(offset, textLength) }; + } + currentPos += textLength; + } + + return null; + } +} + +/** + * Creates a CursorManager instance for the given element. + */ +export function createCursorManager(element: HTMLElement): CursorManager { + return new CursorManager(element); +} diff --git a/src/prompt-input/utils/keyboard-handlers.ts b/src/prompt-input/utils/keyboard-handlers.ts new file mode 100644 index 0000000000..eb77ba2d41 --- /dev/null +++ b/src/prompt-input/utils/keyboard-handlers.ts @@ -0,0 +1,139 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { MenuItemsHandlers, MenuItemsState } from '../menus/menu-controller'; + +export interface KeyboardHandlerDeps { + menuOpen: boolean; + menuItemsState: MenuItemsState | null; + menuItemsHandlers: MenuItemsHandlers | null; + onAction?: (detail: PromptInputProps.ActionDetail) => void; + onModeRemoved?: () => void; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + tokens?: readonly PromptInputProps.InputToken[]; + getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string; + deleteTokenAtCursor: () => boolean; + closeMenu: () => void; +} + +/** + * Creates keyboard event handlers for contentEditable prompt input. + * Handles menu navigation, token deletion, and form submission. + */ +export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { + /** + * Handles menu navigation keys (ArrowUp, ArrowDown, Enter, Escape). + * @returns true if the event was handled, false otherwise + */ + function handleMenuNavigation(event: React.KeyboardEvent): boolean { + if (!deps.menuOpen || !deps.menuItemsHandlers || !deps.menuItemsState) { + return false; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (deps.menuItemsState.items.length - 1 === deps.menuItemsState.highlightedIndex) { + deps.menuItemsHandlers.goHomeWithKeyboard(); + } else { + deps.menuItemsHandlers.moveHighlightWithKeyboard(1); + } + return true; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + if ( + (deps.menuItemsState.highlightedOption?.type === 'child' && deps.menuItemsState.highlightedIndex === 1) || + deps.menuItemsState.highlightedIndex === 0 + ) { + deps.menuItemsHandlers.goEndWithKeyboard(); + } else { + deps.menuItemsHandlers.moveHighlightWithKeyboard(-1); + } + return true; + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (deps.menuItemsHandlers.selectHighlightedOptionWithKeyboard()) { + return true; + } + } + + if (event.key === 'Escape') { + event.preventDefault(); + deps.closeMenu(); + return true; + } + + return false; + } + + /** + * Handles Enter key for form submission and action triggering. + */ + function handleEnterKey(event: React.KeyboardEvent): void { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) { + return; + } + + const form = (event.currentTarget as HTMLElement).closest('form'); + if (form && !event.isDefaultPrevented()) { + form.requestSubmit(); + } + event.preventDefault(); + + const plainText = deps.tokensToText ? deps.tokensToText(deps.tokens ?? []) : deps.getPromptText(deps.tokens ?? []); + + if (deps.onAction) { + deps.onAction({ value: plainText, tokens: [...(deps.tokens ?? [])] }); + } + } + + /** + * Handles Backspace key for token deletion. + */ + function handleBackspaceKey(event: React.KeyboardEvent): boolean { + if (event.key !== 'Backspace') { + return false; + } + + const deleted = deps.deleteTokenAtCursor(); + if (deleted) { + event.preventDefault(); + return true; + } + + return false; + } + + /** + * Handles mode token deletion via Backspace. + */ + function handleModeBackspace(event: React.KeyboardEvent, nodeToCheck: Node | null): boolean { + if (event.key !== 'Backspace') { + return false; + } + + if (nodeToCheck?.nodeType === Node.ELEMENT_NODE) { + const element = nodeToCheck as Element; + const tokenType = element.getAttribute('data-token-type'); + + if (tokenType === 'mode' && deps.onModeRemoved) { + event.preventDefault(); + deps.onModeRemoved(); + return true; + } + } + + return false; + } + + return { + handleMenuNavigation, + handleEnterKey, + handleBackspaceKey, + handleModeBackspace, + }; +} diff --git a/src/test-utils/dom/prompt-input/index.ts b/src/test-utils/dom/prompt-input/index.ts index a241b1f79a..cdbcd4c8ec 100644 --- a/src/test-utils/dom/prompt-input/index.ts +++ b/src/test-utils/dom/prompt-input/index.ts @@ -1,18 +1,78 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComponentWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import { ComponentWrapper, createWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import { escapeSelector } from '@cloudscape-design/test-utils-core/utils'; import { act, setNativeValue } from '@cloudscape-design/test-utils-core/utils-dom'; +import OptionWrapper from '../internal/option'; + +import dropdownStyles from '../../../internal/components/dropdown/styles.selectors.js'; +import selectableStyles from '../../../internal/components/selectable-item/styles.selectors.js'; import testutilStyles from '../../../prompt-input/test-classes/styles.selectors.js'; +export class PromptInputMenuWrapper extends ComponentWrapper { + findOptions(): Array { + return this.findAll(`.${selectableStyles['selectable-item']}[data-test-index]`).map( + (elementWrapper: ElementWrapper) => new OptionWrapper(elementWrapper.getElement()) + ); + } + + /** + * Returns an option from the menu. + * + * @param optionIndex 1-based index of the option to select. + */ + findOption(optionIndex: number): OptionWrapper | null { + return this.findComponent( + `.${selectableStyles['selectable-item']}[data-test-index="${optionIndex}"]`, + OptionWrapper + ); + } + + /** + * Returns an option from the menu by its value + * + * @param value The 'value' of the option. + */ + findOptionByValue(value: string): OptionWrapper | null { + const toReplace = escapeSelector(value); + return this.findComponent(`.${OptionWrapper.rootSelector}[data-value="${toReplace}"]`, OptionWrapper); + } + + findOpenMenu(): ElementWrapper | null { + return this.find(`.${dropdownStyles.dropdown}[data-open=true]`); + } +} + +class PortalPromptInputMenuWrapper extends PromptInputMenuWrapper { + findOpenMenu(): ElementWrapper | null { + return createWrapper().find(`.${dropdownStyles.dropdown}[data-open=true]`); + } +} + export default class PromptInputWrapper extends ComponentWrapper { static rootSelector = testutilStyles.root; + /** + * Finds the native textarea element. + * + * Note: When menus are defined, the component uses a contentEditable element instead of a textarea. + * In this case, this method may fail to find the textarea element. Use findContentEditableElement() + * or the getValue()/setValue() methods instead. + */ findNativeTextarea(): ElementWrapper { return this.findByClassName(testutilStyles.textarea)!; } + /** + * Finds the contentEditable element used when menus are defined. + * Returns null if the component does not have menus defined. + */ + findContentEditableElement(): ElementWrapper | null { + return this.find('[contenteditable="true"]'); + } + /** * Finds the action button. Note that, despite its typings, this may return null. */ @@ -35,26 +95,124 @@ export default class PromptInputWrapper extends ComponentWrapper { return this.findByClassName(testutilStyles['primary-action']); } + /** + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + findMenu(options = { expandMenusToViewport: false }): PromptInputMenuWrapper { + return options.expandMenusToViewport + ? createWrapper().findComponent(`.${dropdownStyles.dropdown}[data-open=true]`, PortalPromptInputMenuWrapper)! + : new PromptInputMenuWrapper(this.getElement()); + } + /** + * Gets the value of the component. + * + * Returns the current value of the textarea (when no menus are defined) or the text content of the contentEditable element (when menus are defined). + */ + @usesDom getValue(): string { + const contentEditable = this.findContentEditableElement(); + if (contentEditable) { + return contentEditable.getElement().textContent || ''; + } + const textarea = this.findNativeTextarea(); + return textarea ? textarea.getElement().value : ''; + } + + /** + * Sets the value of the component by directly setting text content. + * This does NOT trigger menu detection. Use the component ref's insertText() method + * to simulate typing and trigger menus. + * + * @param value String value to set the component to. + */ + @usesDom setValue(value: string): void { + const contentEditable = this.findContentEditableElement(); + if (contentEditable) { + const element = contentEditable.getElement(); + act(() => { + element.textContent = value; + element.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); + }); + } else { + this.setTextareaValue(value); + } + } + + /** + * @deprecated Use getValue() instead. + * * Gets the value of the component. * * Returns the current value of the textarea. */ @usesDom getTextareaValue(): string { - return this.findNativeTextarea().getElement().value; + return this.getValue(); } /** + * @deprecated Use setValue() instead. + * * Sets the value of the component and calls the onChange handler. * * @param value value to set the textarea to. */ @usesDom setTextareaValue(value: string): void { - const element = this.findNativeTextarea().getElement(); + const textarea = this.findNativeTextarea(); + if (textarea) { + const element = textarea.getElement(); + act(() => { + const event = new Event('change', { bubbles: true, cancelable: false }); + setNativeValue(element, value); + element.dispatchEvent(event); + }); + } + } + + /** + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + @usesDom + isMenuOpen(options = { expandMenusToViewport: false }): boolean { + return this.findMenu(options).findOpenMenu() !== null; + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param value value of option to select + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + @usesDom + selectMenuOptionByValue(value: string, options = { expandMenusToViewport: false }): void { + act(() => { + const menu = this.findMenu(options); + const option = menu.findOptionByValue(value); + if (!option) { + throw new Error(`Option with value "${value}" not found in menu`); + } + option.fireEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param optionIndex 1-based index of the option to select + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + @usesDom + selectMenuOption(optionIndex: number, options = { expandMenusToViewport: false }): void { act(() => { - const event = new Event('change', { bubbles: true, cancelable: false }); - setNativeValue(element, value); - element.dispatchEvent(event); + const menu = this.findMenu(options); + const option = menu.findOption(optionIndex); + if (!option) { + throw new Error(`Option at index ${optionIndex} not found in menu`); + } + option.fireEvent(new MouseEvent('mouseup', { bubbles: true })); }); } }