From ea268fd704794519a42b741253460bd16f220760 Mon Sep 17 00:00:00 2001 From: Vahab Valiyev Date: Wed, 4 Nov 2026 10:56:51 +0100 Subject: [PATCH] chore: Adds internal style api v2 to prompt input --- pages/prompt-input/style-api-v2.page.tsx | 136 +++++++++++++ pages/prompt-input/style-api-v2.scss | 141 +++++++++++++ src/prompt-input/__tests__/style-api.test.tsx | 189 ++++++++++++++++++ src/prompt-input/components/token-mode.tsx | 3 + src/prompt-input/internal.tsx | 66 +++++- src/prompt-input/styles.scss | 97 +++++++-- src/prompt-input/tokens/use-token-mode.tsx | 14 +- 7 files changed, 623 insertions(+), 23 deletions(-) create mode 100644 pages/prompt-input/style-api-v2.page.tsx create mode 100644 pages/prompt-input/style-api-v2.scss create mode 100644 src/prompt-input/__tests__/style-api.test.tsx diff --git a/pages/prompt-input/style-api-v2.page.tsx b/pages/prompt-input/style-api-v2.page.tsx new file mode 100644 index 00000000000..4187c27c2c0 --- /dev/null +++ b/pages/prompt-input/style-api-v2.page.tsx @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import clsx from 'clsx'; + +import { PromptInput, PromptInputProps, SpaceBetween } from '~components'; + +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +const menus: PromptInputProps.MenuDefinition[] = [ + { + id: 'mentions', + trigger: '@', + filteringType: 'auto', + options: [ + { value: 'user-1', label: 'Alice' }, + { value: 'user-2', label: 'Bob' }, + { value: 'user-3', label: 'Charlie', disabled: true }, + ], + }, +]; + +const referenceTokens: PromptInputProps.InputToken[] = [ + { type: 'text', value: 'Ask ' }, + { type: 'reference', id: 'ref-1', label: 'Alice', value: 'user-1', menuId: 'mentions' }, + { type: 'text', value: ' about the release' }, +]; + +// Reference token ids become element ids, so the second instance on the page needs its own. +const disabledReferenceTokens: PromptInputProps.InputToken[] = [ + { type: 'text', value: 'Ask ' }, + { type: 'reference', id: 'ref-2', label: 'Alice', value: 'user-1', menuId: 'mentions' }, + { type: 'text', value: ' about the release' }, +]; + +function getStyleClassNames({ invalid, warning, disabled, readOnly }: PromptInputProps) { + const state = invalid ? 'invalid' : warning ? 'warning' : disabled ? 'disabled' : readOnly ? 'readonly' : null; + return { + root: state ? styles[`root-${state}`] : styles.root, + textarea: clsx({ + [styles['textarea-disabled']]: disabled, + [styles['textarea-readonly']]: readOnly && !disabled, + [styles.textarea]: !disabled && !readOnly, + }), + actionButton: styles['action-button'], + token: disabled ? styles['token-disabled'] : styles.token, + menu: styles.menu, + }; +} + +function StyledPromptInput({ value: initialValue = '', ...rest }: Omit) { + const [value, setValue] = useState(initialValue); + return ( + setValue(detail.value)} + {...{ styleClassNames: getStyleClassNames(rest) }} + /> + ); +} + +function StyledTokenPromptInput({ tokens: initialTokens = [], ...rest }: Omit) { + const [tokens, setTokens] = useState(initialTokens); + return ( + setTokens(detail.tokens ?? [])} + {...{ styleClassNames: getStyleClassNames(rest) }} + /> + ); +} + +export default function () { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/pages/prompt-input/style-api-v2.scss b/pages/prompt-input/style-api-v2.scss new file mode 100644 index 00000000000..adfece9937e --- /dev/null +++ b/pages/prompt-input/style-api-v2.scss @@ -0,0 +1,141 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/prompt-input'; +@use '~components/styling/button'; +@use '~components/styling/token'; +@use '~components/styling/dropdown'; +@use '../style-api/palette' as palette; + +@mixin field { + @include prompt-input.root( + $background: palette.$surface, + $border-color: palette.$border, + $border-width: 2px, + $border-radius: 12px, + $box-shadow: 0 2px 4px palette.$subtle + ); +} + +.root { + @include field; + + &:hover { + @include prompt-input.root($background: palette.$surface-alt, $border-color: palette.$border-accent); + } + + &:focus-within { + @include prompt-input.root($border-color: palette.$accent, $box-shadow: 0 0 0 3px palette.$accent-active); + } +} + +.root-invalid { + @include field; + @include prompt-input.root($background: palette.$error-surface, $border-color: palette.$error); + + &:focus-within { + @include prompt-input.root($box-shadow: 0 0 0 3px palette.$error-hover); + } +} + +.root-warning { + @include field; + @include prompt-input.root($background: palette.$warning-surface, $border-color: palette.$warning); + + &:focus-within { + @include prompt-input.root($box-shadow: 0 0 0 3px palette.$warning); + } +} + +.root-disabled { + @include field; + @include prompt-input.root( + $background: palette.$control-bg-disabled, + $border-color: palette.$border-disabled, + $box-shadow: none + ); +} + +.root-readonly { + @include field; + @include prompt-input.root($background: palette.$surface-alt); + + &:focus-within { + @include prompt-input.root($border-color: palette.$accent, $box-shadow: 0 0 0 3px palette.$accent-active); + } +} + +@mixin text { + @include prompt-input.textarea( + $color: palette.$text, + $padding-block: 10px, + $padding-inline: 16px, + $font-family: monospace, + $font-size: 15px, + $font-weight: 600, + $placeholder-color: palette.$accent, + $placeholder-font-size: 13px, + $placeholder-font-style: normal, + $placeholder-font-weight: 400 + ); +} + +.textarea { + @include text; + + &:hover { + @include prompt-input.textarea($color: palette.$accent-hover); + } + + &:focus { + @include prompt-input.textarea($color: palette.$accent); + } +} + +.textarea-disabled { + @include text; + @include prompt-input.textarea($color: palette.$text-disabled, $placeholder-color: palette.$text-disabled); +} + +.textarea-readonly { + @include text; + @include prompt-input.textarea($color: palette.$text, $font-weight: 400); +} + +.action-button { + @include button.root($color: palette.$accent, $focus-ring-color: palette.$accent); + + &:hover { + @include button.root($color: palette.$accent-hover); + } +} + +.token { + @include token.root( + $background: palette.$genai-surface, + $color: palette.$genai-text, + $border-color: palette.$genai, + $border-radius: 6px + ); +} + +.token-disabled { + @include token.root( + $background: palette.$control-bg-disabled, + $color: palette.$text-disabled, + $border-color: palette.$border-disabled, + $border-radius: 6px + ); +} + +.menu { + @include dropdown.container( + $background: palette.$surface-alt, + $border-radius: 12px, + $box-shadow: 0 4px 12px palette.$subtle-active, + $outline-color: palette.$accent, + $outline-width: 2px + ); +} diff --git a/src/prompt-input/__tests__/style-api.test.tsx b/src/prompt-input/__tests__/style-api.test.tsx new file mode 100644 index 00000000000..52023da9f58 --- /dev/null +++ b/src/prompt-input/__tests__/style-api.test.tsx @@ -0,0 +1,189 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { act, render } from '@testing-library/react'; + +import PromptInput, { PromptInputProps } from '../../../lib/components/prompt-input'; +import { StyleClassNames } from '../../../lib/components/prompt-input/internal'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import buttonStyles from '../../../lib/components/button/styles.css.js'; +import dropdownStyles from '../../../lib/components/dropdown/styles.css.js'; +import tokenStyles from '../../../lib/components/token/styles.css.js'; + +jest.mock('@cloudscape-design/component-toolkit', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit'), + useContainerQuery: () => [800, () => {}], +})); + +jest.mock('../../../lib/components/internal/utils/react-version', () => ({ + getReactMajorVersion: () => 18, +})); + +const menus: PromptInputProps.MenuDefinition[] = [ + { id: 'mentions', trigger: '@', options: [{ value: 'user-1', label: 'Alice' }], filteringType: 'auto' }, +]; + +function renderPromptInput(props: Partial & { styleClassNames?: StyleClassNames } = {}) { + const { container } = render(); + return { container, wrapper: createWrapper(container).findPromptInput()! }; +} + +function StatefulTokenMode({ + innerRef, + ...props +}: Partial & { styleClassNames?: StyleClassNames; innerRef: React.Ref }) { + const [tokens, setTokens] = useState([]); + return ( + setTokens(detail.tokens ?? [])} + ref={innerRef} + /> + ); +} + +describe('PromptInput Style API v2', () => { + test('applies the root and textarea classes in textarea mode', () => { + const { wrapper } = renderPromptInput({ styleClassNames: { root: 'a', textarea: 'b' } }); + expect(wrapper.getElement()).toHaveClass('a'); + expect(wrapper.findNativeTextarea().getElement()).toHaveClass('b'); + expect(wrapper.findNativeTextarea().getElement()).not.toHaveClass('a'); + }); + + test('applies the textarea class to the editable element in token mode', () => { + const { wrapper } = renderPromptInput({ tokens: [], menus, styleClassNames: { root: 'a', textarea: 'b' } }); + expect(wrapper.getElement()).toHaveClass('a'); + expect(wrapper.findContentEditableElement()!.getElement()).toHaveClass('b'); + }); + + test('forwards the action button class to the built-in action button root only', () => { + const { wrapper } = renderPromptInput({ styleClassNames: { actionButton: 'a' } }); + expect(wrapper.findActionButton().getElement()).toHaveClass(buttonStyles.button); + expect(wrapper.findActionButton().getElement()).toHaveClass('a'); + + const { container } = renderPromptInput({ + customPrimaryAction: , + styleClassNames: { actionButton: 'a' }, + }); + expect(container.querySelector('.a')).toBeNull(); + }); + + test('forwards the token class to every inline reference token', () => { + const { container } = renderPromptInput({ + menus, + tokens: [ + { type: 'reference', id: 'r1', label: 'Alice', value: 'user-1', menuId: 'mentions' }, + { type: 'text', value: ' and ' }, + { type: 'reference', id: 'r2', label: 'Bob', value: 'user-2', menuId: 'mentions' }, + ], + styleClassNames: { token: 'a' }, + }); + const tokenRoots = container.querySelectorAll(`.${tokenStyles['token-box-inline']}`); + expect(tokenRoots).toHaveLength(2); + for (const tokenRoot of Array.from(tokenRoots)) { + expect(tokenRoot).toHaveClass('a'); + } + }); + + test('forwards the menu class to the dropdown container of an open menu', () => { + const ref = React.createRef(); + const { container } = render(); + const wrapper = createWrapper(container).findPromptInput()!; + expect(document.querySelector('.a')).toBeNull(); + act(() => { + ref.current!.focus(); + }); + act(() => { + ref.current!.insertText('@', 0, 1); + }); + expect(wrapper.isMenuOpen()).toBe(true); + const panel = createWrapper().findByClassName(dropdownStyles['dropdown-content-wrapper'])!.getElement(); + expect(panel).toHaveClass('a'); + }); + + test('does not pass the prop to the DOM', () => { + const { wrapper } = renderPromptInput({ styleClassNames: { root: 'a' } }); + expect(wrapper.getElement()).not.toHaveAttribute('styleClassNames'); + expect(wrapper.getElement()).not.toHaveAttribute('styleclassnames'); + }); + + describe('height adjustment', () => { + let heights: string[]; + + beforeEach(() => { + heights = []; + jest.spyOn(window.CSSStyleDeclaration.prototype, 'height', 'set').mockImplementation((value: string) => { + if (value !== 'auto') { + heights.push(value); + } + }); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + function mockTextElementPadding(padding: string) { + const originalGetComputedStyle = window.getComputedStyle; + jest.spyOn(window, 'getComputedStyle').mockImplementation((element: Element, pseudo?: string | null) => { + const computed = originalGetComputedStyle(element, pseudo); + const isPadding = (property: string | symbol) => property === 'paddingTop' || property === 'paddingBottom'; + return element.classList.contains('a') + ? new Proxy(computed, { + get: (target, property) => (isPadding(property) ? padding : Reflect.get(target, property)), + }) + : computed; + }); + } + + describe.each([ + ['textarea mode', {}], + ['token mode', { tokens: [], menus }], + ])('%s', (_, modeProps) => { + const renderElement = (styleClassNames: StyleClassNames) => ( + + ); + + test.each(['root', 'textarea', 'token', 'actionButton'])('re-measures only when the %s class changes', slot => { + const { rerender } = render(renderElement({ [slot]: 'a' })); + const initialCount = heights.length; + expect(initialCount).toBeGreaterThan(0); + + rerender(renderElement({ [slot]: 'a' })); + expect(heights).toHaveLength(initialCount); + + rerender(renderElement({ [slot]: 'b' })); + expect(heights.length).toBeGreaterThan(initialCount); + }); + }); + + test.each([ + [ + 'the floor and the maxRows bound in textarea mode', + { minRows: 1, maxRows: 3 }, + /^min\(max\(.+, calc\(.+ \+ 24px \+ 24px\)\), calc\(3 \* .+ \+ \(24px \+ 24px - 2 \* var\(.+\)\)\)\)$/, + ], + [ + 'the minRows bound in token mode', + { tokens: [], menus, minRows: 3, maxRows: -1 }, + /^max\(.+, calc\(3 \* .+ \+ \(24px \+ 24px - 2 \* var\(.+\)\)\)\)$/, + ], + ])('adds the computed block padding to %s', (_, props, formula) => { + mockTextElementPadding('24px'); + renderPromptInput({ ...props, styleClassNames: { textarea: 'a' } }); + expect(heights[heights.length - 1]).toMatch(formula); + }); + + test('falls back to the stock padding when the computed padding is not numeric', () => { + mockTextElementPadding(''); + renderPromptInput({ tokens: [], menus, minRows: 3, maxRows: -1, styleClassNames: { textarea: 'a' } }); + expect(heights[heights.length - 1]).toMatch(/\((var\(--space-field-vertical[^)]+\)) \+ \1 - 2 \* \1\)\)\)$/); + }); + }); +}); diff --git a/src/prompt-input/components/token-mode.tsx b/src/prompt-input/components/token-mode.tsx index 585a1c3fcfd..dd8f0a00e1f 100644 --- a/src/prompt-input/components/token-mode.tsx +++ b/src/prompt-input/components/token-mode.tsx @@ -57,6 +57,7 @@ interface TokenModeProps { maxMenuHeight?: number; menuStyle?: PromptInputMenuStyle; + styleClassNames?: { menu?: string }; } const MENU_MIN_WIDTH = 300; @@ -81,6 +82,7 @@ export default function TokenMode({ menuDropdownStatus, maxMenuHeight, menuStyle, + styleClassNames, handleInput, handleLoadMore, editableElementAttributes, @@ -122,6 +124,7 @@ export default function TokenMode({ minWidth={MENU_MIN_WIDTH} maxHeight={maxMenuHeight} expandToViewport={true} + styleClassNames={{ container: styleClassNames?.menu }} style={ menuStyle ? ({ diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index 63b99567506..7dc564a59e0 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -41,7 +41,24 @@ import testutilStyles from './test-classes/styles.css.js'; interface InternalPromptInputProps extends SomeRequired, - InternalBaseComponentProps {} + InternalBaseComponentProps { + styleClassNames?: StyleClassNames; +} + +// Style API v2 +export interface StyleClassNames { + root?: string; + textarea?: string; + actionButton?: string; + token?: string; + menu?: string; +} + +// An empty computed value (detached element) falls back to the stock padding, which keeps the row bounds unchanged. +function toPaddingLength(computedValue: string, fallback: string) { + const pixels = parseFloat(computedValue); + return isNaN(pixels) ? fallback : `${pixels}px`; +} const InternalPromptInput = React.forwardRef( ( @@ -87,6 +104,7 @@ const InternalPromptInput = React.forwardRef( onTriggerDetected, i18nStrings, __internalRootRef, + styleClassNames, ...rest }: InternalPromptInputProps, ref: Ref @@ -170,15 +188,20 @@ const InternalPromptInput = React.forwardRef( // The native textarea already grows to its `rows={minRows}` attribute, so this // formula only needs to act as a one-line floor there. The contentEditable div used // in token mode has no equivalent native sizing, so it must scale with minRows directly. + // The difference from the stock padding is 0 unless a consumer padding is set. + const computedStyle = getComputedStyle(element); + const paddingTop = toPaddingLength(computedStyle.paddingTop, designTokens.spaceFieldVertical); + const paddingBottom = toPaddingLength(computedStyle.paddingBottom, designTokens.spaceFieldVertical); + const paddingDelta = `(${paddingTop} + ${paddingBottom} - 2 * ${designTokens.spaceFieldVertical})`; const minRowsHeight = isTokenMode - ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})` - : `calc(${LINE_HEIGHT} + ${designTokens.spaceFieldVertical} * 2)`; + ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING} + ${paddingDelta})` + : `calc(${LINE_HEIGHT} + ${paddingTop} + ${paddingBottom})`; const scrollHeight = `calc(${element.scrollHeight}px)`; if (maxRows === -1) { element.style.height = `max(${scrollHeight}, ${minRowsHeight})`; } else { - const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; + const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING} + ${paddingDelta})`; element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; } @@ -190,11 +213,23 @@ const InternalPromptInput = React.forwardRef( // Height is adjusted per-mode. `value` must not be a dependency in token // mode — it's unused there, and re-running the effect on `value` changes // resets the contentEditable caret to offset 0. + // The root, textarea, token and actionButton classes can change the text layout (padding, font, token width, + // button width), so a change to any of them re-measures; the menu is a portaled dropdown and cannot. useEffect(() => { if (isTokenMode) { requestAnimationFrame(() => adjustInputHeight()); } - }, [isTokenMode, tokens, adjustInputHeight, isCompactMode, placeholder]); + }, [ + isTokenMode, + tokens, + adjustInputHeight, + isCompactMode, + placeholder, + styleClassNames?.root, + styleClassNames?.textarea, + styleClassNames?.token, + styleClassNames?.actionButton, + ]); // When minRows/maxRows change, recalculate height synchronously — these prop // changes don't involve caret position so no RAF deferral is needed. @@ -208,7 +243,19 @@ const InternalPromptInput = React.forwardRef( if (!isTokenMode) { adjustInputHeight(); } - }, [isTokenMode, value, adjustInputHeight, isCompactMode, placeholder, minRows, maxRows]); + }, [ + isTokenMode, + value, + adjustInputHeight, + isCompactMode, + placeholder, + minRows, + maxRows, + styleClassNames?.root, + styleClassNames?.textarea, + styleClassNames?.token, + styleClassNames?.actionButton, + ]); // Observe width changes on the component's root element to handle container resizes. // We need a separate RefObject because __internalRootRef may be a callback ref, @@ -255,6 +302,7 @@ const InternalPromptInput = React.forwardRef( onMenuLoadItems, i18nStrings: effectiveI18nStrings, adjustInputHeight, + styleClassNames, }); const handleInsertText = useStableCallback((text: string, caretStart?: number, caretEnd?: number) => { @@ -364,7 +412,7 @@ const InternalPromptInput = React.forwardRef( name, placeholder, autoFocus, - className: clsx(styles.textarea, testutilStyles.textarea, { + className: clsx(styleClassNames?.textarea, styles.textarea, testutilStyles.textarea, { [styles.invalid]: invalid, [styles.warning]: warning, }), @@ -388,6 +436,7 @@ const InternalPromptInput = React.forwardRef( {customPrimaryAction ?? ( void; + + styleClassNames?: { textarea?: string; token?: string }; } /** Return value of useTokenMode — state, handlers, and attributes consumed by TokenMode component. */ @@ -382,6 +384,7 @@ export function useTokenMode(config: UseTokenModeConfig): UseTokenModeResult { onMenuLoadItems, i18nStrings, adjustInputHeight, + styleClassNames, } = config; const shortcutsState = useShortcutsState(); @@ -1097,7 +1100,7 @@ export function useTokenMode(config: UseTokenModeConfig): UseTokenModeResult { 'aria-readonly': readOnly ? 'true' : undefined, 'aria-required': ariaRequired ? 'true' : undefined, 'data-placeholder': placeholder, - className: clsx(styles.textarea, testutilStyles.textarea, { + className: clsx(styleClassNames?.textarea, styles.textarea, testutilStyles.textarea, { [styles.invalid]: invalid, [styles.warning]: warning, [styles['textarea-disabled']]: disabled, @@ -1144,7 +1147,14 @@ export function useTokenMode(config: UseTokenModeConfig): UseTokenModeResult { const portals = portalContainers.map(container => ReactDOM.createPortal( - , + , container.element ) );