diff --git a/pages/input/adornments.page.tsx b/pages/input/adornments.page.tsx new file mode 100644 index 0000000000..ed558b75ca --- /dev/null +++ b/pages/input/adornments.page.tsx @@ -0,0 +1,768 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Badge from '~components/badge'; +import Box from '~components/box'; +import ColumnLayout from '~components/column-layout'; +import Container from '~components/container'; +import ContentLayout from '~components/content-layout'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import Input from '~components/input'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; +import Toggle from '~components/toggle'; + +// ─── State controls panel ───────────────────────────────────────────────────── + +interface StateControlsProps { + disabled: boolean; + setDisabled: (v: boolean) => void; + readOnly: boolean; + setReadOnly: (v: boolean) => void; + invalid: boolean; + setInvalid: (v: boolean) => void; + warning: boolean; + setWarning: (v: boolean) => void; + compact: boolean; + setCompact: (v: boolean) => void; + rtl: boolean; + setRtl: (v: boolean) => void; +} + +function StateControls({ + disabled, + setDisabled, + readOnly, + setReadOnly, + invalid, + setInvalid, + warning, + setWarning, + compact, + setCompact, + rtl, + setRtl, +}: StateControlsProps) { + return ( + State controls}> + + + setDisabled(detail.checked)}> + Disabled + + setReadOnly(detail.checked)}> + Read-only + + + + setInvalid(detail.checked)}> + Invalid + + setWarning(detail.checked)}> + Warning + + + + setCompact(detail.checked)}> + Compact density + + setRtl(detail.checked)}> + RTL layout + + + + + ); +} + +// ─── Format hint examples — string adornments ───────────────────────────────── + +interface StateProps { + disabled: boolean; + readOnly: boolean; + invalid: boolean; + warning: boolean; +} + +function FormatHintExamples({ disabled, readOnly, invalid, warning }: StateProps) { + const [currency, setCurrency] = useState(''); + const [percent, setPercent] = useState(''); + const [compound, setCompound] = useState(''); + const [both, setBoth] = useState(''); + const [ms, setMs] = useState(''); + + return ( + + Format hint adornments — string + + Decorative + + + } + > + + + + setCurrency(e.detail.value)} + prefix="$" + inputMode="decimal" + placeholder="0.00" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setPercent(e.detail.value)} + suffix="%" + inputMode="numeric" + placeholder="0" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setCompound(e.detail.value)} + suffix="TB/mo" + inputMode="numeric" + placeholder="0" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setMs(e.detail.value)} + suffix="ms" + inputMode="numeric" + placeholder="0" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setBoth(e.detail.value)} + prefix="<" + suffix="%" + inputMode="decimal" + placeholder="0.5" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + + ); +} + +// ─── ReactNode adornments — icons ───────────────────────────────────────────── + +function ReactNodeIconExamples({ disabled, readOnly, invalid, warning }: StateProps) { + const [search, setSearch] = useState(''); + const [email, setEmail] = useState(''); + const [url, setUrl] = useState(''); + const [amount, setAmount] = useState(''); + const [locked, setLocked] = useState(''); + + return ( + + ReactNode adornments — Icons + + ReactNode + + + } + > + + + + setSearch(e.detail.value)} + prefix={ + + + setEmail(e.detail.value)} + prefix={ + + + setUrl(e.detail.value)} + prefix={ + + + setAmount(e.detail.value)} + prefix={ + + + setLocked(e.detail.value)} + prefix={ + + + + ); +} + +// ─── ReactNode adornments — Badges ──────────────────────────────────────────── + +function ReactNodeBadgeExamples({ disabled, readOnly, invalid, warning }: StateProps) { + const [beta, setBeta] = useState(''); + const [required, setRequired] = useState(''); + const [new_, setNew_] = useState(''); + + return ( + + ReactNode adornments — Badges + + ReactNode + + + } + > + + + + setBeta(e.detail.value)} + suffix={Beta} + placeholder="my-feature-flag" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setRequired(e.detail.value)} + suffix={Required} + inputMode="numeric" + placeholder="123456789012" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setNew_(e.detail.value)} + suffix={New} + placeholder="arn:aws:bedrock:..." + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + + ); +} + +// ─── ReactNode adornments — StatusIndicator ─────────────────────────────────── + +function ReactNodeStatusExamples({ disabled, readOnly, invalid, warning }: StateProps) { + const [endpoint, setEndpoint] = useState(''); + const [pending, setPending] = useState(''); + + return ( + + ReactNode adornments — StatusIndicator + + ReactNode + + + } + > + + + + setEndpoint(e.detail.value)} + suffix={Connected} + placeholder="db.example.com:5432" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setPending(e.detail.value)} + suffix={Queued} + placeholder="job-abc123" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + + ); +} + +// ─── ReactNode adornments — icon + text combination ─────────────────────────── + +function ReactNodeCompositeExamples({ disabled, readOnly, invalid, warning }: StateProps) { + const [rate, setRate] = useState(''); + const [storage, setStorage] = useState(''); + + return ( + + ReactNode adornments — Icon + text composite + + ReactNode + + + } + > + + + + setRate(e.detail.value)} + suffix={ + + + } + inputMode="numeric" + placeholder="1000" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + + setStorage(e.detail.value)} + prefix={ + + + + ); +} + +// ─── Required prefix/suffix (userland concatenation) ────────────────────────── + +function RequiredAffixExamples({ disabled, readOnly, invalid, warning }: StateProps) { + const [url, setUrl] = useState(''); + const [bucket, setBucket] = useState(''); + const [host, setHost] = useState(''); + + return ( + + Required prefix/suffix + + Builder concatenates + + + } + > + + + + + setUrl(e.detail.value)} + prefix="https://" + type="url" + placeholder="example.com/webhook" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + {url && ( + + Submitted value:{' '} + + https://{url} + + + )} + + + + + + setBucket(e.detail.value)} + prefix="amazon-braket-" + placeholder="my-bucket" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + {bucket && ( + + Submitted value:{' '} + + amazon-braket-{bucket} + + + )} + + + + + + setHost(e.detail.value)} + suffix=".ec2.internal" + placeholder="my-host" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + {host && ( + + Submitted value:{' '} + + {host}.ec2.internal + + + )} + + + + + + ); +} + +// ─── Baseline comparison ────────────────────────────────────────────────────── + +function BaselineComparison({ disabled, readOnly, invalid, warning }: StateProps) { + const [value, setValue] = useState(''); + return ( + + Baseline: plain Input + + } + > + + setValue(e.detail.value)} + placeholder="Enter a value" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + warning={warning} + /> + + + ); +} + +// ─── RTL section ────────────────────────────────────────────────────────────── + +function RtlSection() { + const [val, setVal] = useState(''); + const [val2, setVal2] = useState(''); + const [val3, setVal3] = useState(''); + return ( + RTL layout}> + + In RTL locales,{' '} + + prefix + {' '} + appears on the trailing edge and{' '} + + suffix + {' '} + appears on the leading edge. The prop names are semantic (before/after the value), not directional. Icons flip + correctly because the flex container inherits{' '} + + dir + + . + + +
+ + + setVal(e.detail.value)} + prefix="$" + inputMode="decimal" + placeholder="0.00" + /> + + + setVal2(e.detail.value)} + suffix="%" + inputMode="numeric" + placeholder="0" + /> + + + setVal3(e.detail.value)} + prefix={ + +
+
+
+ ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function AdornmentsPage() { + const [disabled, setDisabled] = useState(false); + const [readOnly, setReadOnly] = useState(false); + const [invalid, setInvalid] = useState(false); + const [warning, setWarning] = useState(false); + const [compact, setCompact] = useState(false); + const [rtl, setRtl] = useState(false); + + const stateProps = { disabled, readOnly, invalid, warning }; + + return ( +
+ + Input: prefix/suffix adornments (ReactNode) + + } + > + + + + + + + + + + + + +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index d835dc74ff..7a7c25be6f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -17298,7 +17298,48 @@ with the input using \`ariaDescribedby\`.", "type": "boolean", }, ], - "regions": [], + "regions": [ + { + "description": "Content rendered in a shaded cell on the leading edge of the input field, +before the editable value. + +Accepts any React node — plain text, a short symbol, an icon, or a badge. +Use for contextual decorators such as currency symbols (\`$\`, \`€\`), +URL schemes (\`https://\`), path prefixes (\`/api/v2/\`), or an icon that +clarifies the expected data type. + +The adornment is purely decorative — it does not mutate the \`value\` prop +or the \`onChange\` detail. Wrap any icon or custom element in +\`aria-hidden="true"\` if it is already conveyed by the visible +\`\` label, so screen reader users are not double-announced. +Ensure the unit or context is also reflected in the visible label +(for example, label = "Amount in US dollars", not just "Amount"). + +In RTL locales the \`prefix\` cell appears on the trailing edge. +"prefix" means "before the value" semantically, not "on the left". + +Don't use \`prefix\` as a substitute for a visible label.", + "isDefault": false, + "name": "prefix", + }, + { + "description": "Content rendered in a shaded cell on the trailing edge of the input field, +after the editable value. + +Accepts any React node — plain text, a short unit abbreviation, an icon, +or a badge. Use for units of measure (\`%\`, \`ms\`, \`TB/mo\`, \`GB\`, \`req/s\`, +\`days\`), currency codes (\`USD\`, \`EUR\`), degree notation (\`°C\`, \`°E\`), or +an icon indicating a secondary action. + +The adornment is purely decorative — it does not mutate the \`value\` prop +or the \`onChange\` detail. Ensure the unit is also reflected in the visible +\`\` label (for example, label = "CPU utilization (percent)"). + +In RTL locales the \`suffix\` cell appears on the leading edge.", + "isDefault": false, + "name": "suffix", + }, + ], "releaseStatus": "stable", } `; @@ -35226,6 +35267,34 @@ Supported options: ], }, }, + { + "description": "Returns the prefix adornment element, or null if no prefix is set.", + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the suffix adornment element, or null if no suffix is set.", + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.focus", @@ -36880,6 +36949,23 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "description": "Returns the prefix adornment element, or null if no prefix is set.", + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findStatusIndicator", "parameters": [ @@ -36903,6 +36989,23 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "description": "Returns the suffix adornment element, or null if no suffix is set.", + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.focus", @@ -43165,6 +43268,23 @@ and this method will have no effect.", ], }, }, + { + "description": "Returns the prefix adornment element, or null if no prefix is set.", + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Returns custom property form cancel button.", "name": "findPropertyCancelButton", @@ -43256,6 +43376,23 @@ and this method will have no effect.", ], }, }, + { + "description": "Returns the suffix adornment element, or null if no suffix is set.", + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findTokens", "parameters": [], @@ -47245,6 +47382,24 @@ Supported options: "name": "ElementWrapper", }, }, + { + "description": "Returns the prefix adornment element, or null if no prefix is set.", + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the suffix adornment element, or null if no suffix is set.", + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "InputWrapper", }, @@ -48381,6 +48536,18 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "description": "Returns the prefix adornment element, or null if no prefix is set.", + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findStatusIndicator", "parameters": [ @@ -48401,6 +48568,18 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "description": "Returns the suffix adornment element, or null if no suffix is set.", + "inheritedFrom": { + "name": "InputWrapper.findSuffix", + }, + "name": "findSuffix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "AutosuggestWrapper", }, @@ -52863,6 +53042,18 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "description": "Returns the prefix adornment element, or null if no prefix is set.", + "inheritedFrom": { + "name": "InputWrapper.findPrefix", + }, + "name": "findPrefix", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Returns custom property form cancel button.", "name": "findPropertyCancelButton", @@ -52945,6 +53136,18 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "description": "Returns the suffix adornment element, or null if no suffix is set.", + "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 4b1af42bc5..ed248e6f53 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/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..1f75e611f5 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 React from 'react'; + import { BaseComponentProps } from '../types/base-component'; import { BaseKeyDetail, CancelableEventHandler, NonCancelableEventHandler } from '../types/events'; import { FormFieldValidationControlProps } from '../types/form-field'; @@ -188,6 +190,50 @@ export interface InputProps * @awsuiSystem core */ style?: InputProps.Style; + + /** + * Content rendered in a shaded cell on the leading edge of the input field, + * before the editable value. + * + * Accepts any React node — plain text, a short symbol, an icon, or a badge. + * Use for contextual decorators such as currency symbols (`$`, `€`), + * URL schemes (`https://`), path prefixes (`/api/v2/`), or an icon that + * clarifies the expected data type. + * + * The adornment is purely decorative — it does not mutate the `value` prop + * or the `onChange` detail. Wrap any icon or custom element in + * `aria-hidden="true"` if it is already conveyed by the visible + * `` label, so screen reader users are not double-announced. + * Ensure the unit or context is also reflected in the visible label + * (for example, label = "Amount in US dollars", not just "Amount"). + * + * In RTL locales the `prefix` cell appears on the trailing edge. + * "prefix" means "before the value" semantically, not "on the left". + * + * Don't use `prefix` as a substitute for a visible label. + * + * @see suffix + */ + prefix?: React.ReactNode; + + /** + * Content rendered in a shaded cell on the trailing edge of the input field, + * after the editable value. + * + * Accepts any React node — plain text, a short unit abbreviation, an icon, + * or a badge. Use for units of measure (`%`, `ms`, `TB/mo`, `GB`, `req/s`, + * `days`), currency codes (`USD`, `EUR`), degree notation (`°C`, `°E`), or + * an icon indicating a secondary action. + * + * The adornment is purely decorative — it does not mutate the `value` prop + * or the `onChange` detail. Ensure the unit is also reflected in the visible + * `` label (for example, label = "CPU utilization (percent)"). + * + * In RTL locales the `suffix` cell appears on the leading edge. + * + * @see prefix + */ + suffix?: React.ReactNode; } export namespace InputProps { diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 7dad564dbe..516da8f39d 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -57,6 +57,9 @@ export interface InternalInputProps __skipNativeAttributesWarnings?: SkipWarnings; __inlineLabelText?: string; __fullWidth?: boolean; + + prefix?: React.ReactNode; + suffix?: React.ReactNode; } function InternalInput( @@ -101,6 +104,8 @@ function InternalInput( __inlineLabelText, __fullWidth, style, + prefix, + suffix, ...rest }: InternalInputProps, ref: Ref @@ -142,6 +147,7 @@ function InternalInput( __rightIcon && styles['input-has-icon-right'], __leftIcon && styles['input-has-icon-left'], __noBorderRadius && styles['input-has-no-border-radius'], + (prefix !== undefined || suffix !== undefined) && styles['input-adorned'], { [styles['input-readonly']]: readOnly, [styles['input-invalid']]: invalid, @@ -215,6 +221,25 @@ function InternalInput( /> ); + // The input element, optionally wrapped in an inline-label structure. + const inputWithLabel = __inlineLabelText ? ( +
+ +
+ {mainInput} +
+
+ ) : ( + mainInput + ); + return (
)} - {__inlineLabelText ? ( -
- -
- {mainInput} -
+ {prefix !== undefined || suffix !== undefined ? ( + // One flex bar owns the border, radius, background, and focus ring. + // Layout: [prefix][divider][input][divider][suffix] + // Dividers only render on sides that have an adornment. +
+ {prefix !== undefined && ( + + )} + {prefix !== undefined && } + {inputWithLabel} + {suffix !== undefined && } + {suffix !== undefined && ( + + )}
) : ( - mainInput + inputWithLabel )} {__rightIcon && (