diff --git a/build-tools/jest/setup.js b/build-tools/jest/setup.js index 19a1ca30b5..1a0fe4876e 100644 --- a/build-tools/jest/setup.js +++ b/build-tools/jest/setup.js @@ -7,4 +7,14 @@ if (typeof window !== 'undefined') { require('@testing-library/jest-dom/extend-expect'); const { cleanup } = require('@testing-library/react'); afterEach(cleanup); + + // jsdom doesn't implement ResizeObserver. Provide a no-op mock so components + // that use it (e.g. PromptInput) can render without errors in unit tests. + if (!window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + } } diff --git a/pages/prompt-input/container-resize.page.tsx b/pages/prompt-input/container-resize.page.tsx new file mode 100644 index 0000000000..24653c7d29 --- /dev/null +++ b/pages/prompt-input/container-resize.page.tsx @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { FormField, RadioGroup, SpaceBetween } from '~components'; +import PromptInput, { PromptInputProps } from '~components/prompt-input'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +const longText = + 'This is a long message that should cause the prompt input to grow in height when the container becomes narrower, because the text wraps to more lines. Adding even more text to ensure wrapping.'; + +const sampleTokens: PromptInputProps.InputToken[] = [ + { type: 'text', value: longText }, + { type: 'reference', id: 'ref-1', label: 'Alice', value: 'alice', menuId: 'mentions' }, + { type: 'text', value: ' and some more text that adds to the wrapping behavior of the component.' }, +]; + +const menus: PromptInputProps.MenuDefinition[] = [ + { + id: 'mentions', + trigger: '@', + options: [ + { value: 'alice', label: 'Alice' }, + { value: 'bob', label: 'Bob' }, + ], + }, +]; + +type PageContext = React.Context>; + +export default function ContainerResizePage() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const isTokenMode = urlParams.inputMode === 'token'; + const containerWidth = Number(urlParams.containerWidth) || 400; + + const [tokens, setTokens] = useState(sampleTokens); + const [value, setValue] = useState( + longText + ' Alice and some more text that adds to the wrapping behavior of the component.' + ); + + return ( + + + + + setUrlParams({ containerWidth: detail.value })} + items={[ + { value: '400', label: '400px' }, + { value: '600', label: '600px' }, + { value: '800', label: '800px' }, + { value: '1200', label: '1200px' }, + ]} + /> + + + + setUrlParams({ inputMode: detail.value })} + items={[ + { value: 'token', label: 'Token mode' }, + { value: 'plain', label: 'Plain text' }, + ]} + /> + + + +
+ { + setValue(event.detail.value); + if (event.detail.tokens) { + setTokens(event.detail.tokens); + } + }} + tokens={isTokenMode ? tokens : undefined} + menus={isTokenMode ? menus : undefined} + maxRows={10} + placeholder="Type here..." + data-testid="prompt-input" + /> +
+
+
+ ); +} diff --git a/src/internal/hooks/use-width-change/__tests__/use-width-change.test.tsx b/src/internal/hooks/use-width-change/__tests__/use-width-change.test.tsx new file mode 100644 index 0000000000..ecbc3aaa88 --- /dev/null +++ b/src/internal/hooks/use-width-change/__tests__/use-width-change.test.tsx @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useRef } from 'react'; + +import { renderHook } from '../../../../__tests__/render-hook'; +import { useWidthChange } from '../index'; + +describe('useWidthChange', () => { + test('does not throw when ref is null', () => { + const onWidthChange = jest.fn(); + + expect(() => { + renderHook(() => { + const ref = useRef(null); + useWidthChange(ref, onWidthChange); + }); + }).not.toThrow(); + + expect(onWidthChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/internal/hooks/use-width-change/index.ts b/src/internal/hooks/use-width-change/index.ts new file mode 100644 index 0000000000..74cc5908ee --- /dev/null +++ b/src/internal/hooks/use-width-change/index.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useRef } from 'react'; + +/** + * Observes an element for inline-size (width) changes and calls `onWidthChange` + * when the width changes. Height-only changes are ignored to prevent infinite + * loops when the callback adjusts the element's height. + * + * Unlike useResizeObserver from the component-toolkit package, it does not cause + * re-renders when the width changes. + * + * @param elementRef - A ref object pointing to the element to observe. + * @param onWidthChange - Callback fired when the element's width changes. + */ +export function useWidthChange(elementRef: React.RefObject, onWidthChange: () => void): void { + const lastWidthRef = useRef(-1); + + useEffect(() => { + const node = elementRef.current; + if (!node) { + return; + } + const observer = new ResizeObserver(() => { + const newWidth = node.getBoundingClientRect().width; + if (newWidth !== lastWidthRef.current) { + lastWidthRef.current = newWidth; + onWidthChange(); + } + }); + observer.observe(node); + return () => observer.disconnect(); + }, [elementRef, onWidthChange]); +} diff --git a/src/prompt-input/__integ__/prompt-input-container-resize.test.ts b/src/prompt-input/__integ__/prompt-input-container-resize.test.ts new file mode 100644 index 0000000000..cfe7bff5bc --- /dev/null +++ b/src/prompt-input/__integ__/prompt-input-container-resize.test.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors/index.js'; +import { isReact18 } from './utils'; + +const wrapper = createWrapper(); +const promptInputSelector = wrapper.findPromptInput('[data-testid="prompt-input"]').toSelector(); + +class ContainerResizePage extends BasePageObject { + async getPromptInputHeight() { + const { height } = await this.getBoundingBox(promptInputSelector); + return height; + } + + async selectWidth(value: string) { + await this.click(wrapper.findRadioGroup('[data-testid="width-radio"]').findInputByValue(value).toSelector()); + } +} + +describe.each(isReact18 ? ['token', 'plain'] : ['plain'])('Prompt Input - Container Resize (mode=%s)', mode => { + test( + 'adjusts height when container width changes', + useBrowser(async browser => { + const page = new ContainerResizePage(browser); + await browser.url(`#/prompt-input/container-resize?inputMode=${mode}&containerWidth=400`); + await page.waitForVisible(promptInputSelector); + + // Start at 400px — text should be wrapping, so height is tall + const heightAt400 = await page.getPromptInputHeight(); + + // Widen to 1200px — text should unwrap, height should decrease + await page.selectWidth('1200'); + await page.pause(500); + const heightAt1200 = await page.getPromptInputHeight(); + + expect(heightAt1200).toBeLessThan(heightAt400); + + // Narrow back to 400px — height should increase again + await page.selectWidth('400'); + await page.pause(500); + const heightAt400Again = await page.getPromptInputHeight(); + + expect(heightAt400Again).toBeGreaterThan(heightAt1200); + }) + ); +}); diff --git a/src/prompt-input/__integ__/prompt-input-token-mode.test.ts b/src/prompt-input/__integ__/prompt-input-token-mode.test.ts index 670ffff5ba..cd67b4b6b7 100644 --- a/src/prompt-input/__integ__/prompt-input-token-mode.test.ts +++ b/src/prompt-input/__integ__/prompt-input-token-mode.test.ts @@ -3,13 +3,13 @@ import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; -import createWrapper from '../../../lib/components/test-utils/selectors/index.js'; +import createWrapper from '../../../lib/components/test-utils/selectors/index'; +import { isReact18 } from './utils'; const promptInputWrapper = createWrapper().findPromptInput('[data-testid="prompt-input"]'); const contentEditableSelector = promptInputWrapper.findContentEditableElement()!.toSelector(); const textareaSelector = promptInputWrapper.findNativeTextarea()!.toSelector(); const menuSelector = promptInputWrapper.findOpenMenu()!.toSelector(); -const isReact18 = process.env.REACT_VERSION === '18'; class PromptInputTokenModePage extends BasePageObject { async focusInput() { diff --git a/src/prompt-input/__integ__/utils.ts b/src/prompt-input/__integ__/utils.ts new file mode 100644 index 0000000000..94572595e8 --- /dev/null +++ b/src/prompt-input/__integ__/utils.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const isReact18 = process.env.REACT_VERSION === '18'; diff --git a/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx b/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx index bcec785d65..e8d70d86dc 100644 --- a/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx +++ b/src/prompt-input/__tests__/prompt-input-token-mode.test.tsx @@ -5181,33 +5181,53 @@ describe('token mode misc', () => { expect(getCaretOffset()).toBe(3); }); - test('unmounting cleans up resize listener and clears portal containers', () => { - const resizeSpy = jest.spyOn(window, 'removeEventListener'); + test('unmounting cleans up ResizeObserver and clears portal containers', () => { + const originalResizeObserver = window.ResizeObserver; + const disconnectSpy = jest.fn(); + const observeSpy = jest.fn(); + const MockResizeObserver = jest.fn(() => ({ + observe: observeSpy, + unobserve: jest.fn(), + disconnect: disconnectSpy, + })); + window.ResizeObserver = MockResizeObserver; + const { wrapper, container } = renderTokenMode({ props: { tokens: [{ type: 'reference', id: 'r1', label: 'Alice', value: 'user-1', menuId: 'mentions' }] }, }); // Verify reference is rendered expect(getValue(wrapper)).toContain('Alice'); - // Unmount — this should clean up resize listener and clear containers + expect(observeSpy).toHaveBeenCalled(); + // Unmount — this should disconnect the ResizeObserver const { unmount } = render(
, { container }); unmount(); - // Verify resize listener was cleaned up - const resizeCalls = resizeSpy.mock.calls.filter(([event]) => event === 'resize'); - expect(resizeCalls.length).toBeGreaterThan(0); - resizeSpy.mockRestore(); + expect(disconnectSpy).toHaveBeenCalled(); + window.ResizeObserver = originalResizeObserver; }); - test('window resize triggers height adjustment without error', () => { + test('ResizeObserver triggers height adjustment without error', () => { + const originalResizeObserver = window.ResizeObserver; + let resizeCallback: () => void = () => {}; + const MockResizeObserver = jest.fn(cb => ({ + observe: () => { + resizeCallback = cb; + }, + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + window.ResizeObserver = MockResizeObserver; + const ref = React.createRef(); const { wrapper } = renderTokenMode({ props: { tokens: [{ type: 'text', value: 'hello' }] }, ref }); act(() => { ref.current!.focus(); }); act(() => { - window.dispatchEvent(new Event('resize')); + resizeCallback(); }); // The component should still render correctly and preserve content after resize expect(getValue(wrapper)).toBe('hello'); + window.ResizeObserver = originalResizeObserver; }); test('Enter key fires onAction through the keyboard handler config', () => { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index 2e4ec2a0ee..4a042cc067 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -3,7 +3,12 @@ import React, { Ref, useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; -import { useDensityMode, useStableCallback, warnOnce } from '@cloudscape-design/component-toolkit/internal'; +import { + useDensityMode, + useMergeRefs, + useStableCallback, + warnOnce, +} from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; import { useInternalI18n } from '../i18n/context'; @@ -14,6 +19,7 @@ import { fireCancelableEvent, fireKeyboardEvent, fireNonCancelableEvent } from ' 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 { useWidthChange } from '../internal/hooks/use-width-change'; import { isDevelopment } from '../internal/is-development'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; @@ -190,6 +196,13 @@ const InternalPromptInput = React.forwardRef( } }, [isTokenMode, value, adjustInputHeight, isCompactMode, placeholder]); + // 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, + // and useWidthChange needs a RefObject with .current to read the DOM node. + const rootElementRef = useRef(null); + const mergedRootRef = useMergeRefs(__internalRootRef, rootElementRef); + useWidthChange(rootElementRef, adjustInputHeight); + const plainTextValue = isTokenMode ? tokensToText ? tokensToText(tokens ?? []) @@ -390,7 +403,7 @@ const InternalPromptInput = React.forwardRef( [styles['textarea-warning']]: warning && !invalid, [styles.disabled]: disabled, })} - ref={__internalRootRef} + ref={mergedRootRef} role="region" style={getPromptInputStyles(style)} > diff --git a/src/prompt-input/tokens/use-token-mode.tsx b/src/prompt-input/tokens/use-token-mode.tsx index c5d7c795a4..1dce2f07da 100644 --- a/src/prompt-input/tokens/use-token-mode.tsx +++ b/src/prompt-input/tokens/use-token-mode.tsx @@ -998,15 +998,6 @@ export function useTokenMode(config: UseTokenModeConfig): UseTokenModeResult { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - const ownerWindow = (editableElementRef.current?.ownerDocument ?? document).defaultView ?? window; - const handleResize = () => adjustInputHeight(); - ownerWindow.addEventListener('resize', handleResize); - return () => { - ownerWindow.removeEventListener('resize', handleResize); - }; - }, [adjustInputHeight, editableElementRef]); - const menuLoadMoreResult = useMenuLoadMore({ menu: activeMenu ?? { id: '', trigger: '', options: [] }, statusType: activeMenu?.statusType ?? 'finished',