diff --git a/package.json b/package.json index 875717c3b1..0ba9e6c444 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "1370 kB", + "limit": "1380 kB", "ignore": "react-dom" } ], diff --git a/pages/input/permutations.page.tsx b/pages/input/permutations.page.tsx index 9c55bd9c66..f60a98e7fa 100644 --- a/pages/input/permutations.page.tsx +++ b/pages/input/permutations.page.tsx @@ -8,6 +8,8 @@ import createPermutations from '../utils/permutations'; import PermutationsView from '../utils/permutations-view'; import ScreenshotArea from '../utils/screenshot-area'; +const longAdornment = 'A long decorative prefix or suffix that must not cover the editable value'; + const permutations = createPermutations([ { disabled: [false, true], @@ -47,6 +49,14 @@ const permutations = createPermutations([ value: ['100000000'], placeholder: ['Short placeholder'], }, + { value: ['10'], prefix: ['$'], suffix: [undefined] }, + { value: ['10'], prefix: [undefined], suffix: ['USD'] }, + { value: ['10'], prefix: ['$'], suffix: ['USD'], disabled: [false, true], readOnly: [false, true] }, + { value: ['10'], prefix: ['$'], suffix: ['USD'], invalid: [true], warning: [false] }, + { value: ['10'], prefix: ['$'], suffix: ['USD'], invalid: [false], warning: [true] }, + { type: ['search'], value: ['', 'query'], prefix: ['$'], suffix: ['USD'], clearAriaLabel: ['Clear'] }, + { value: ['10'], prefix: [longAdornment], suffix: [longAdornment] }, + { value: ['10'], prefix: ['$'], suffix: ['USD'] }, ]); export default function InputPermutations() { diff --git a/pages/input/prefix-suffix.page.tsx b/pages/input/prefix-suffix.page.tsx new file mode 100644 index 0000000000..b9918c2de6 --- /dev/null +++ b/pages/input/prefix-suffix.page.tsx @@ -0,0 +1,148 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext } from 'react'; + +import Box from '~components/box'; +import FormField from '~components/form-field'; +import Icon from '~components/icon'; +import Input, { InputProps } from '~components/input'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; +import Toggle from '~components/toggle'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +type PrefixSuffixMode = 'none' | 'short' | 'long' | 'icon'; + +type PageContext = React.Context< + AppContextType<{ + type?: InputProps.Type; + prefix?: PrefixSuffixMode; + suffix?: PrefixSuffixMode; + disabled?: boolean; + readOnly?: boolean; + invalid?: boolean; + warning?: boolean; + value?: string; + }> +>; + +const typeOptions: ReadonlyArray = [ + { value: 'text', label: 'Text' }, + { value: 'password', label: 'Password' }, + { value: 'number', label: 'Number' }, + { value: 'email', label: 'Email' }, + { value: 'url', label: 'URL' }, + { value: 'search', label: 'Search' }, +]; + +const prefixSuffixOptions: ReadonlyArray = [ + { value: 'none', label: 'None' }, + { value: 'short', label: 'Short text' }, + { value: 'long', label: 'Long text' }, + { value: 'icon', label: 'Icon' }, +]; + +function getPrefix(mode: PrefixSuffixMode): React.ReactNode { + switch (mode) { + case 'short': + return '$'; + case 'long': + return 'https://example.com/very-long-prefix-that-overflows'; + case 'icon': + return ; + default: + return undefined; + } +} + +function getSuffix(mode: PrefixSuffixMode): React.ReactNode { + switch (mode) { + case 'short': + return '%'; + case 'long': + return '.ec2.internal.very-long-domain-that-overflows'; + case 'icon': + return ; + default: + return undefined; + } +} + +export default function PrefixSuffixPage() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const type = urlParams.type ?? 'text'; + const prefixMode = urlParams.prefix ?? 'short'; + const suffixMode = urlParams.suffix ?? 'short'; + const disabled = urlParams.disabled ?? false; + const readOnly = urlParams.readOnly ?? false; + const invalid = urlParams.invalid ?? false; + const warning = urlParams.warning ?? false; + + return ( + + option.value === prefixMode) ?? null} + options={prefixSuffixOptions} + onChange={({ detail }) => setUrlParams({ prefix: detail.selectedOption.value as PrefixSuffixMode })} + /> + setUrlParams({ value: detail.value })} + type={type} + prefix={getPrefix(prefixMode)} + suffix={getSuffix(suffixMode)} + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + placeholder="Enter a value" + clearAriaLabel="Clear" + /> + + + {type === 'search' && ( + + Prefix and suffix are ignored for search inputs. + + )} + + ); +} diff --git a/pages/input/style-permutations.page.tsx b/pages/input/style-permutations.page.tsx index dd2a0d5dc0..21cf58abf4 100644 --- a/pages/input/style-permutations.page.tsx +++ b/pages/input/style-permutations.page.tsx @@ -15,6 +15,8 @@ const permutations = createPermutations([ disabled: [false, true], readOnly: [false, true], onChange: [() => {}], + prefix: [undefined, '$'], + suffix: [undefined, 'USD'], style: [ { root: { diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index e872e9fbab..9b479f6e5e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -17404,7 +17404,18 @@ with the input using \`ariaDescribedby\`.", "type": "boolean", }, ], - "regions": [], + "regions": [ + { + "description": "Use for content rendered before the editable value.", + "isDefault": false, + "name": "prefix", + }, + { + "description": "Use for content rendered after the editable value.", + "isDefault": false, + "name": "suffix", + }, + ], "releaseStatus": "stable", } `; @@ -35505,6 +35516,32 @@ Supported options: ], }, }, + { + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.focus", @@ -37159,6 +37196,22 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findStatusIndicator", "parameters": [ @@ -37182,6 +37235,22 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.focus", @@ -43461,6 +43530,22 @@ and this method will have no effect.", ], }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Returns custom property form cancel button.", "name": "findPropertyCancelButton", @@ -43552,6 +43637,22 @@ and this method will have no effect.", ], }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTokens", "parameters": [], @@ -48000,6 +48101,22 @@ Supported options: "name": "ElementWrapper", }, }, + { + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "InputWrapper", }, @@ -49136,6 +49253,17 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findStatusIndicator", "parameters": [ @@ -49156,6 +49284,17 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "AutosuggestWrapper", }, @@ -53635,6 +53774,17 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Returns custom property form cancel button.", "name": "findPropertyCancelButton", @@ -53717,6 +53867,17 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findTokens", "parameters": [], diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 16e98a9c41..0934607914 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -378,6 +378,8 @@ exports[`test-utils selectors 1`] = ` "input": [ "awsui_input-button-right_2rhyz", "awsui_input-container_2rhyz", + "awsui_input-prefix_2rhyz", + "awsui_input-suffix_2rhyz", "awsui_input_2rhyz", "awsui_root_2rhyz", ], diff --git a/src/input/__tests__/adornments.test.tsx b/src/input/__tests__/adornments.test.tsx new file mode 100644 index 0000000000..ac1a87d037 --- /dev/null +++ b/src/input/__tests__/adornments.test.tsx @@ -0,0 +1,166 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import '../../__a11y__/to-validate-a11y'; +import Input, { InputProps } from '../../../lib/components/input'; +import InternalInput from '../../../lib/components/input/internal'; +import customCssProps from '../../../lib/components/internal/generated/custom-css-properties'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/input/styles.css.js'; + +function renderInput(props: Partial = {}) { + const { container, rerender } = render( {}} {...props} />); + const wrapper = createWrapper(container).findInput()!; + return { wrapper, rerender }; +} + +describe('prefix and suffix adornments', () => { + test('does not render adornment container, prefix, or suffix by default', () => { + const { wrapper } = renderInput(); + expect(wrapper.findByClassName(styles['input-adorned-container'])).toBeNull(); + expect(wrapper.findPrefix()).toBeNull(); + expect(wrapper.findSuffix()).toBeNull(); + expect(wrapper.findNativeInput().getElement()).not.toHaveClass(styles['input-adorned']); + }); + + test('renders a prefix', () => { + const { wrapper } = renderInput({ prefix: '$' }); + expect(wrapper.findPrefix()!.getElement()).toHaveTextContent('$'); + expect(wrapper.findSuffix()).toBeNull(); + expect(wrapper.findByClassName(styles['input-adorned-container'])).not.toBeNull(); + expect(wrapper.findNativeInput().getElement()).toHaveClass(styles['input-adorned']); + }); + + test('renders a suffix', () => { + const { wrapper } = renderInput({ suffix: '%' }); + expect(wrapper.findSuffix()!.getElement()).toHaveTextContent('%'); + expect(wrapper.findPrefix()).toBeNull(); + expect(wrapper.findNativeInput().getElement()).toHaveClass(styles['input-adorned']); + }); + + test('renders both a prefix and a suffix', () => { + const { wrapper } = renderInput({ prefix: 'https://', suffix: '.com' }); + expect(wrapper.findPrefix()!.getElement()).toHaveTextContent('https://'); + expect(wrapper.findSuffix()!.getElement()).toHaveTextContent('.com'); + }); + + test('renders a divider only on sides that have an adornment', () => { + const { wrapper, rerender } = renderInput({ prefix: '$' }); + expect(wrapper.findAllByClassName(styles['input-adornment-divider'])).toHaveLength(1); + + rerender( {}} prefix="$" suffix="%" />); + expect(wrapper.findAllByClassName(styles['input-adornment-divider'])).toHaveLength(2); + }); + + test('renders arbitrary React nodes', () => { + const { wrapper } = renderInput({ prefix: node }); + expect(wrapper.findPrefix()!.find('[data-testid="custom"]')).not.toBeNull(); + }); + + test('renders truthy numeric adornments', () => { + const { wrapper } = renderInput({ prefix: 1, suffix: 1 }); + expect(wrapper.findPrefix()!.getElement()).toHaveTextContent('1'); + expect(wrapper.findSuffix()!.getElement()).toHaveTextContent('1'); + }); + + test('applies custom Input styles to the adorned container', () => { + const style: InputProps['style'] = { + root: { + backgroundColor: { default: '#ffffff', hover: '#f2f3f3' }, + borderColor: { default: '#000000', hover: '#111111' }, + borderRadius: '6px', + borderWidth: '2px', + color: { default: '#222222', disabled: '#999999' }, + paddingBlock: '12px', + paddingInline: '16px', + }, + }; + const { wrapper } = renderInput({ prefix: '$', style }); + const container = wrapper.findByClassName(styles['input-adorned-container'])!.getElement(); + + expect(container).toHaveStyle({ borderRadius: '6px', borderWidth: '2px' }); + expect(container.style.getPropertyValue(customCssProps.styleBackgroundHover)).toBe('#f2f3f3'); + expect(container.style.getPropertyValue(customCssProps.styleBorderColorHover)).toBe('#111111'); + expect(container.style.getPropertyValue(customCssProps.styleColorDisabled)).toBe('#999999'); + + // paddingBlock and paddingInline go to the native input, NOT the container + const nativeInput = wrapper.findNativeInput()!.getElement(); + expect(nativeInput.style.paddingBlock).toBe('12px'); + expect(nativeInput.style.paddingInline).toBe('16px'); + }); + + test('contains the end icon within the adorned focus container', () => { + const { container } = render( {}} prefix="$" __endIcon="settings" />); + const adornedContainer = container.querySelector(`.${styles['input-adorned-container']}`)!; + const endIcon = container.querySelector(`.${styles['input-icon-end']}`)!; + + expect(adornedContainer).toContainElement(endIcon as HTMLElement); + endIcon.querySelector('button')!.focus(); + expect(adornedContainer.contains(document.activeElement)).toBe(true); + }); + + test.each([null, false, 0, undefined, ''] as const)( + 'does not render an adornment cell or divider for non-rendered React child %p', + absentContent => { + const { wrapper } = renderInput({ prefix: absentContent, suffix: absentContent }); + expect(wrapper.findPrefix()).toBeNull(); + expect(wrapper.findSuffix()).toBeNull(); + expect(wrapper.findByClassName(styles['input-adorned-container'])).toBeNull(); + expect(wrapper.findAllByClassName(styles['input-adornment-divider'])).toHaveLength(0); + } + ); + + describe('adornment container reflects validation and interaction state', () => { + const getContainer = (props: Partial) => + renderInput({ prefix: '$', ...props }) + .wrapper.findByClassName(styles['input-adorned-container'])! + .getElement(); + + test('adds the invalid modifier when invalid', () => { + expect(getContainer({ invalid: true })).toHaveClass(styles['input-adorned-container-invalid']); + }); + + test('prefers the invalid modifier over the warning modifier', () => { + const container = getContainer({ invalid: true, warning: true }); + expect(container).toHaveClass(styles['input-adorned-container-invalid']); + expect(container).not.toHaveClass(styles['input-adorned-container-warning']); + }); + + test('adds the warning modifier when warning and not invalid', () => { + expect(getContainer({ warning: true })).toHaveClass(styles['input-adorned-container-warning']); + }); + + test('adds the disabled modifier when disabled', () => { + expect(getContainer({ disabled: true })).toHaveClass(styles['input-adorned-container-disabled']); + }); + + test('adds the readonly modifier when readOnly and not disabled', () => { + const container = getContainer({ readOnly: true }); + expect(container).toHaveClass(styles['input-adorned-container-readonly']); + }); + + test('prefers the disabled modifier over the readonly modifier', () => { + const container = getContainer({ disabled: true, readOnly: true }); + expect(container).toHaveClass(styles['input-adorned-container-disabled']); + expect(container).not.toHaveClass(styles['input-adorned-container-readonly']); + }); + }); + + describe('accessibility', () => { + test('marks adornments as decorative with aria-hidden', () => { + const { wrapper } = renderInput({ prefix: '$', suffix: '%' }); + expect(wrapper.findPrefix()!.getElement()).toHaveAttribute('aria-hidden', 'true'); + expect(wrapper.findSuffix()!.getElement()).toHaveAttribute('aria-hidden', 'true'); + }); + + test('has no axe violations', async () => { + const { container } = render( + {}} ariaLabel="Amount" prefix="$" suffix="USD" /> + ); + await expect(container).toValidateA11y(); + }); + }); +}); diff --git a/src/input/__tests__/analytics-metadata.test.tsx b/src/input/__tests__/analytics-metadata.test.tsx index dac07a4b46..bc8c52601f 100644 --- a/src/input/__tests__/analytics-metadata.test.tsx +++ b/src/input/__tests__/analytics-metadata.test.tsx @@ -38,7 +38,7 @@ beforeAll(() => { activateAnalyticsMetadata(true); }); describe('Input renders correct analytics metadata', () => { - describe('on the right button', () => { + describe('on the end button', () => { test('when it is the clear button', () => { const renderResult = render( {}} type="search" clearAriaLabel="clear" />); const clearInputButton = createWrapper(renderResult.container).findInput()!.findClearButton()!.getElement(); @@ -51,11 +51,11 @@ describe('Input renders correct analytics metadata', () => { }); }); test('when it is not the clear button', () => { - const renderResult = render( {}} __rightIcon="settings" />); - const rightIconButton = createWrapper(renderResult.container) - .findByClassName(styles['input-icon-right'])! + const renderResult = render( {}} __endIcon="settings" />); + const endIconButton = createWrapper(renderResult.container) + .findByClassName(styles['input-icon-end'])! .getElement(); - expect(getGeneratedAnalyticsMetadata(rightIconButton)).toEqual({}); + expect(getGeneratedAnalyticsMetadata(endIconButton)).toEqual({}); }); }); describe('on the component', () => { diff --git a/src/input/__tests__/search-input.test.tsx b/src/input/__tests__/search-input.test.tsx index e1f06a3267..e0ff917dbf 100644 --- a/src/input/__tests__/search-input.test.tsx +++ b/src/input/__tests__/search-input.test.tsx @@ -3,9 +3,16 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + import Input, { InputProps } from '../../../lib/components/input'; import createWrapper, { InputWrapper } from '../../../lib/components/test-utils/dom'; +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: jest.fn(), +})); + function renderInput( props: Omit & { value?: string } & React.RefAttributes = {} ) { @@ -21,6 +28,74 @@ function getClearButton(wrapper: InputWrapper) { return wrapper.findClearButton()!.getElement(); } +describe('Development warnings for prefix/suffix on search type', () => { + afterEach(() => { + (warnOnce as jest.Mock).mockReset(); + }); + + test('warns when prefix is supplied with type search', () => { + renderInput({ type: 'search', prefix: '$' }); + expect(warnOnce).toHaveBeenCalledWith('Input', 'prefix and suffix are ignored when type is search.'); + }); + + test('warns when suffix is supplied with type search', () => { + renderInput({ type: 'search', suffix: '%' }); + expect(warnOnce).toHaveBeenCalledWith('Input', 'prefix and suffix are ignored when type is search.'); + }); + + test('warns when both prefix and suffix are supplied with type search', () => { + renderInput({ type: 'search', prefix: '$', suffix: '%' }); + expect(warnOnce).toHaveBeenCalledWith('Input', 'prefix and suffix are ignored when type is search.'); + }); + + test('does not warn when neither prefix nor suffix is supplied with type search', () => { + renderInput({ type: 'search' }); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when prefix/suffix are supplied with non-search type', () => { + renderInput({ type: 'text', prefix: '$', suffix: '%' }); + expect(warnOnce).not.toHaveBeenCalled(); + }); +}); + +describe('Prefix/suffix suppression for search type', () => { + test('does not render prefix when type is search', () => { + const wrapper = renderInput({ type: 'search', prefix: '$' }); + expect(wrapper.findPrefix()).toBeNull(); + }); + + test('does not render suffix when type is search', () => { + const wrapper = renderInput({ type: 'search', suffix: '%' }); + expect(wrapper.findSuffix()).toBeNull(); + }); + + test('does not render prefix and suffix together when type is search', () => { + const wrapper = renderInput({ type: 'search', prefix: 'https://', suffix: '.com' }); + expect(wrapper.findPrefix()).toBeNull(); + expect(wrapper.findSuffix()).toBeNull(); + }); + + test('still renders clear button for populated search input with prefix/suffix props', () => { + const wrapper = renderInput({ type: 'search', value: 'query', prefix: '$', suffix: '%' }); + expect(wrapper.findClearButton()).not.toBeNull(); + expect(wrapper.findPrefix()).toBeNull(); + expect(wrapper.findSuffix()).toBeNull(); + }); + + test('renders prefix for non-search type', () => { + const wrapper = renderInput({ type: 'text', prefix: '$' }); + expect(wrapper.findPrefix()).not.toBeNull(); + expect(wrapper.findPrefix()!.getElement().textContent).toBe('$'); + }); + + test('renders suffix for non-search type', () => { + const wrapper = renderInput({ type: 'text', suffix: '%' }); + expect(wrapper.findSuffix()).not.toBeNull(); + expect(wrapper.findSuffix()!.getElement().textContent).toBe('%'); + }); +}); + describe('Clear field', () => { const baseProps: Omit = { type: 'search', diff --git a/src/input/index.tsx b/src/input/index.tsx index 351bdf14af..f194cff2a6 100644 --- a/src/input/index.tsx +++ b/src/input/index.tsx @@ -44,6 +44,8 @@ const Input = React.forwardRef( clearAriaLabel, nativeInputAttributes, style, + prefix, + suffix, ...rest }: InputProps, ref: Ref @@ -101,6 +103,8 @@ const Input = React.forwardRef( clearAriaLabel, nativeInputAttributes, style, + prefix, + suffix, }} className={clsx(styles.root, baseProps.className)} __inheritFormFieldProps={true} diff --git a/src/input/interfaces.ts b/src/input/interfaces.ts index 86a9cbeffd..614e43c880 100644 --- a/src/input/interfaces.ts +++ b/src/input/interfaces.ts @@ -1,5 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { ReactNode } from 'react'; + import { BaseComponentProps } from '../types/base-component'; import { BaseKeyDetail, CancelableEventHandler, NonCancelableEventHandler } from '../types/events'; import { FormFieldValidationControlProps } from '../types/form-field'; @@ -188,6 +190,16 @@ export interface InputProps * @awsuiSystem core */ style?: InputProps.Style; + + /** + * Use for content rendered before the editable value. + */ + prefix?: ReactNode; + + /** + * Use for content rendered after the editable value. + */ + suffix?: ReactNode; } export namespace InputProps { diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 7dad564dbe..b524f97341 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -3,7 +3,7 @@ import React, { Ref, useRef } from 'react'; import clsx from 'clsx'; -import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; +import { useMergeRefs, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { copyAnalyticsMetadataAttribute, getAnalyticsMetadataAttribute, @@ -18,6 +18,7 @@ import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useDebounceCallback } from '../internal/hooks/use-debounce-callback'; +import { isDevelopment } from '../internal/is-development'; import WithNativeAttributes, { SkipWarnings } from '../internal/utils/with-native-attributes'; import { BaseComponentProps } from '../types/base-component'; import { NonCancelableEventHandler } from '../types/events'; @@ -40,12 +41,12 @@ export interface InternalInputProps FormFieldValidationControlProps, InternalBaseComponentProps { type?: InputProps['type'] | 'visualSearch'; - __leftIcon?: IconProps['name']; - __leftIconVariant?: IconProps['variant']; - __onLeftIconClick?: () => void; + __startIcon?: IconProps['name']; + __startIconVariant?: IconProps['variant']; + __onStartIconClick?: () => void; - __rightIcon?: IconProps['name']; - __onRightIconClick?: () => void; + __endIcon?: IconProps['name']; + __onEndIconClick?: () => void; __noBorderRadius?: boolean; @@ -77,14 +78,14 @@ function InternalInput( spellcheck, __noBorderRadius, - __leftIcon, - __leftIconVariant = 'subtle', - __onLeftIconClick, + __startIcon, + __startIconVariant = 'subtle', + __onStartIconClick, ariaRequired, - __rightIcon, - __onRightIconClick, + __endIcon, + __onEndIconClick, onKeyDown, onKeyUp, @@ -101,6 +102,8 @@ function InternalInput( __inlineLabelText, __fullWidth, style, + prefix, + suffix, ...rest }: InternalInputProps, ref: Ref @@ -116,15 +119,40 @@ function InternalInput( const inputRef = useRef(null); const searchProps = useSearchProps(type, disabled, readOnly, value, inputRef, handleChange); - __leftIcon = __leftIcon ?? searchProps.__leftIcon; - __rightIcon = __rightIcon ?? searchProps.__rightIcon; - __onRightIconClick = __onRightIconClick ?? searchProps.__onRightIconClick; + __startIcon = __startIcon ?? searchProps.__startIcon; + __endIcon = __endIcon ?? searchProps.__endIcon; + __onEndIconClick = __onEndIconClick ?? searchProps.__onEndIconClick; + + // Search inputs use built-in search and clear icons that would overlap adornments. + const isSearch = type === 'search' || type === 'visualSearch'; + if (isDevelopment) { + if (isSearch && (prefix !== undefined || suffix !== undefined)) { + warnOnce('Input', 'prefix and suffix are ignored when type is search.'); + } + } + if (isSearch) { + prefix = undefined; + suffix = undefined; + } const formFieldContext = useFormFieldContext(rest); const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = __inheritFormFieldProps ? formFieldContext : rest; + const hasPrefix = !!prefix; + const hasSuffix = !!suffix; + const hasPrefixOrSuffix = hasPrefix || hasSuffix; + const inputStyles = getInputStyles(style); + const nativeInputStyles = + hasPrefixOrSuffix && inputStyles + ? { ...inputStyles, borderRadius: undefined, borderWidth: undefined } + : inputStyles; + const adornedContainerStyles = + hasPrefixOrSuffix && inputStyles + ? { ...inputStyles, paddingBlock: undefined, paddingInline: undefined } + : undefined; + const attributes: React.InputHTMLAttributes = { 'aria-label': ariaLabel, // aria-labelledby has precedence over aria-label in accessible name calculation. @@ -139,13 +167,14 @@ function InternalInput( className: clsx( styles.input, type && styles[`input-type-${type}`], - __rightIcon && styles['input-has-icon-right'], - __leftIcon && styles['input-has-icon-left'], + __endIcon && styles['input-has-icon-end'], + __startIcon && styles['input-has-icon-start'], __noBorderRadius && styles['input-has-no-border-radius'], + hasPrefixOrSuffix && styles['input-adorned'], { [styles['input-readonly']]: readOnly, - [styles['input-invalid']]: invalid, - [styles['input-warning']]: warning && !invalid, + [styles['input-invalid']]: invalid && !hasPrefixOrSuffix, + [styles['input-warning']]: warning && !invalid && !hasPrefixOrSuffix, } ), autoComplete: convertAutoComplete(autoComplete), @@ -211,10 +240,50 @@ function InternalInput( nativeAttributes={nativeInputAttributes} skipWarnings={__skipNativeAttributesWarnings} ref={mergedRef} - style={getInputStyles(style)} + style={nativeInputStyles} /> ); + const inputWithLabel = __inlineLabelText ? ( +
+ +
+ {mainInput} +
+
+ ) : ( + mainInput + ); + + const endIcon = __endIcon ? ( + ) + : {})} + > + + + ) : null; + return (
- {__leftIcon && ( - - + {__startIcon && ( + + )} - {__inlineLabelText ? ( -
- -
- {mainInput} -
+ {hasPrefixOrSuffix ? ( + // [prefix][divider][input][divider][suffix] - one flex bar owns the border and focus ring. +
+ {hasPrefix && ( + <> + + + + )} + {inputWithLabel} + {hasSuffix && ( + <> + + + + )} + {endIcon}
) : ( - mainInput - )} - {__rightIcon && ( - ) - : {})} - > - - + inputWithLabel )} + {!hasPrefixOrSuffix && endIcon}
); } diff --git a/src/input/styles.scss b/src/input/styles.scss index 4cf24e0338..c09f1874d8 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -28,6 +28,47 @@ } } +%input-default-styles { + background-color: var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-input-default); + border-block: awsui.$border-width-field solid + var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-default); + border-inline: awsui.$border-width-field solid + var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-default); + box-shadow: var(#{custom-props.$styleBoxShadowDefault}); +} + +%input-hover-styles { + border-color: var( + #{custom-props.$styleBorderColorHover}, + var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-default) + ); + background-color: var( + #{custom-props.$styleBackgroundHover}, + var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-input-default) + ); + box-shadow: var(#{custom-props.$styleBoxShadowHover}, #{custom-props.$styleBoxShadowDefault}); +} + +%input-readonly-styles { + @include styles.form-readonly-element( + $background-color: var( + #{custom-props.$styleBackgroundReadonly}, + var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-input-default) + ), + $border-color: var( + #{custom-props.$styleBorderColorReadonly}, + var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-disabled) + ) + ); +} + +%input-focus-styles { + @include styles.form-focus-element( + $border-color: var(#{custom-props.$styleBorderColorFocus}, awsui.$color-border-input-focused), + $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) + ); +} + .root { /* used in test-utils for component to distinguish input from other input-like components, for example autosuggest */ } @@ -40,54 +81,31 @@ inline-size: 100%; cursor: text; box-sizing: border-box; - background-color: var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-input-default); border-start-start-radius: styles.$control-border-radius; border-start-end-radius: styles.$control-border-radius; border-end-start-radius: styles.$control-border-radius; border-end-end-radius: styles.$control-border-radius; - border-block: awsui.$border-width-field solid - var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-default); - border-inline: awsui.$border-width-field solid - var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-default); - - box-shadow: var(#{custom-props.$styleBoxShadowDefault}); - @include styles.font-body-m; min-block-size: awsui.$size-vertical-input; + @extend %input-default-styles; + &:hover { - border-color: var( - #{custom-props.$styleBorderColorHover}, - var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-default) - ); color: var( #{custom-props.$styleColorHover}, var(#{custom-props.$styleBorderColorDefault}, awsui.$color-text-body-default) ); - background-color: var( - #{custom-props.$styleBackgroundHover}, - var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-input-default) - ); - box-shadow: var(#{custom-props.$styleBoxShadowHover}, #{custom-props.$styleBoxShadowDefault}); + @extend %input-hover-styles; } &.input-readonly { - @include styles.form-readonly-element( - $background-color: var( - #{custom-props.$styleBackgroundReadonly}, - var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-input-default) - ), - $border-color: var( - #{custom-props.$styleBorderColorReadonly}, - var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-input-disabled) - ) - ); color: var( #{custom-props.$styleColorReadonly}, var(#{custom-props.$styleColorDefault}, awsui.$color-text-body-default) ); box-shadow: var(#{custom-props.$styleBoxShadowReadonly}); + @extend %input-readonly-styles; } @include placeholder { @@ -100,12 +118,9 @@ } &:focus { - @include styles.form-focus-element( - $border-color: var(#{custom-props.$styleBorderColorFocus}, awsui.$color-border-input-focused), - $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) - ); color: var(#{custom-props.$styleColorFocus}, awsui.$color-text-body-default); background-color: var(#{custom-props.$styleBackgroundFocus}, awsui.$color-background-input-default); + @extend %input-focus-styles; } &:disabled { @@ -134,7 +149,7 @@ $border-color: var(#{custom-props.$styleBorderColorDefault}, awsui.$color-text-status-error), $focus-box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid) ); - &.input-has-icon-left { + &.input-has-icon-start { padding-inline-start: calc( #{styles.$control-icon-horizontal-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) @@ -148,7 +163,7 @@ $border-color: var(#{custom-props.$styleBorderColorDefault}, awsui.$color-text-status-warning), $focus-box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid) ); - &.input-has-icon-left { + &.input-has-icon-start { padding-inline-start: calc( #{styles.$control-icon-horizontal-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) @@ -169,10 +184,10 @@ display: none; } } - &.input-has-icon-left { + &.input-has-icon-start { padding-inline-start: styles.$control-icon-horizontal-padding; } - &.input-has-icon-right { + &.input-has-icon-end { padding-inline-end: styles.$control-icon-horizontal-padding; } &.input-has-no-border-radius { @@ -188,14 +203,14 @@ position: relative; } -.input-icon-left { +.input-icon-start { position: absolute; pointer-events: none; inset-inline-start: styles.$control-icon-horizontal-offset; inset-block-start: styles.$control-icon-vertical-offset; } -.input-icon-right { +.input-icon-end { position: absolute; inset-block-start: calc(#{styles.$control-icon-vertical-offset} - #{awsui.$space-xxxs}); inset-inline-end: calc(#{styles.$control-icon-horizontal-offset} - #{awsui.$space-xxs}); @@ -224,3 +239,182 @@ .inline-label { @include forms.inline-label; } + +.input-adorned-container { + display: flex; + align-items: stretch; + inline-size: 100%; + box-sizing: border-box; + color: var(#{custom-props.$styleColorDefault}, awsui.$color-text-form-secondary); + border-start-start-radius: styles.$control-border-radius; + border-start-end-radius: styles.$control-border-radius; + border-end-start-radius: styles.$control-border-radius; + border-end-end-radius: styles.$control-border-radius; + min-block-size: awsui.$size-vertical-input; + + @extend %input-default-styles; + + &:hover { + @extend %input-hover-styles; + color: var( + #{custom-props.$styleColorHover}, + var(#{custom-props.$styleColorDefault}, awsui.$color-text-form-secondary) + ); + } + + &.input-adorned-container-readonly { + @extend %input-readonly-styles; + color: var( + #{custom-props.$styleColorReadonly}, + var(#{custom-props.$styleColorDefault}, awsui.$color-text-form-secondary) + ); + } + + &:focus-within { + @extend %input-focus-styles; + color: var( + #{custom-props.$styleColorFocus}, + var(#{custom-props.$styleColorDefault}, awsui.$color-text-form-secondary) + ); + } + + &.input-adorned-container-invalid { + @include styles.form-invalid-control( + $color: var(#{custom-props.$styleColorDefault}, awsui.$color-text-status-error), + $border-color: var(#{custom-props.$styleBorderColorDefault}, awsui.$color-text-status-error), + $focus-box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid) + ) { + padding-inline-start: 0; + } + + &:focus-within { + box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid); + } + } + + &.input-adorned-container-warning { + @include styles.form-warning-control( + $color: var(#{custom-props.$styleColorDefault}, awsui.$color-text-status-warning), + $border-color: var(#{custom-props.$styleBorderColorDefault}, awsui.$color-text-status-warning), + $focus-box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid) + ) { + padding-inline-start: 0; + } + + &:focus-within { + box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid); + } + } + + &.input-adorned-container-disabled { + @include styles.form-disabled-element( + $background-color: var(#{custom-props.$styleBackgroundDisabled}, awsui.$color-background-input-disabled), + $border-color: var(#{custom-props.$styleBorderColorDisabled}, awsui.$color-border-input-disabled), + $color: var(#{custom-props.$styleColorDisabled}, awsui.$color-text-input-disabled), + $cursor: default + ); + box-shadow: var(#{custom-props.$styleBoxShadowDisabled}); + } +} + +.input-prefix, +.input-suffix { + display: flex; + align-items: center; + flex-shrink: 1; + max-inline-size: max(0px, calc((100% - 12rem) / 2)); + min-inline-size: 0; + padding-block: styles.$control-padding-vertical; + padding-inline: styles.$control-padding-horizontal; + color: inherit; + @include styles.font-body-m; +} + +.input-adornment-content { + display: block; + flex: 1; + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.input-adornment-divider { + display: block; + flex-shrink: 0; + inline-size: awsui.$border-width-field; + background-color: awsui.$color-border-divider-default; + align-self: stretch; +} + +.input-adorned { + flex: 1; + min-inline-size: 0; + border-block: none; + border-inline: none; + border-start-start-radius: 0; + border-start-end-radius: 0; + border-end-start-radius: 0; + border-end-end-radius: 0; + background-color: transparent; + box-shadow: none; + min-block-size: unset; + + &.input:focus { + border-block: 0; + border-inline: 0; + box-shadow: none; + outline: none; + } + + &:hover { + border-color: transparent; + background-color: transparent; + } + + &:disabled { + background-color: transparent; + border-color: transparent; + border-block: 0; + border-inline: 0; + } + + &.input-readonly { + background-color: transparent; + border-color: transparent; + border-block: 0; + border-inline: 0; + } + + &.input-invalid, + &.input-warning { + padding-inline-start: styles.$control-padding-horizontal; + border-block: 0; + border-inline: 0; + border-inline-start-width: 0; + box-shadow: none; + + &:focus { + border-block: 0; + border-inline: 0; + box-shadow: none; + outline: none; + } + } +} + +.input-adorned-container-invalid, +.input-adorned-container-warning { + > .input-prefix, + > .input-adorned:first-child { + padding-inline-start: styles.$invalid-control-left-padding; + } + + > .input-prefix, + > .input-suffix { + max-inline-size: max( + 0px, + calc((100% - 12rem + #{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) / 2) + ); + } +} diff --git a/src/input/utils.ts b/src/input/utils.ts index 88518aa9cd..895817d137 100644 --- a/src/input/utils.ts +++ b/src/input/utils.ts @@ -18,11 +18,11 @@ export const useSearchProps = ( onChange(''); }, [inputRef, onChange]); if (type === 'search' || type === 'visualSearch') { - searchProps.__leftIcon = 'search'; + searchProps.__startIcon = 'search'; if (!disabled && !readOnly && value) { - searchProps.__rightIcon = 'close'; - searchProps.__onRightIconClick = handleIconClick; + searchProps.__endIcon = 'close'; + searchProps.__onEndIconClick = handleIconClick; } } return searchProps; diff --git a/src/test-utils/dom/input/index.ts b/src/test-utils/dom/input/index.ts index 07afab1659..8a845c3f86 100644 --- a/src/test-utils/dom/input/index.ts +++ b/src/test-utils/dom/input/index.ts @@ -12,4 +12,12 @@ export default class InputWrapper extends BaseInputWrapper { findClearButton(): ElementWrapper | null { return this.find(`.${inputSelectors['input-button-right']}`); } + + findPrefix(): ElementWrapper | null { + return this.find(`.${inputSelectors['input-prefix']}`); + } + + findSuffix(): ElementWrapper | null { + return this.find(`.${inputSelectors['input-suffix']}`); + } }