From 43ce08193396c52aa556ee355b3f958ba92e61d3 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 29 Oct 2026 17:59:39 +0100 Subject: [PATCH 1/9] fix: Adjust spacings in token mode autosuggest --- src/input/internal.tsx | 20 ++- src/input/styles.scss | 48 +++++-- .../__tests__/use-token-overflow.test.tsx | 75 +++++------ .../components/autosuggest-input/index.tsx | 120 ++++++------------ .../components/autosuggest-input/styles.scss | 84 ------------ .../autosuggest-input/use-token-overflow.ts | 39 +++--- 6 files changed, 146 insertions(+), 240 deletions(-) diff --git a/src/input/internal.tsx b/src/input/internal.tsx index d8662a9b66..824534c1e2 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -51,7 +51,10 @@ export interface InternalInputProps __onEndIconClick?: () => void; __noBorderRadius?: boolean; - __noBorder?: boolean; + + // Rendered inside the field, before the native input. + __inlineContent?: React.ReactNode; + __inlineContentRef?: React.Ref; __onDelayedInput?: NonCancelableEventHandler; __onBlurWithDetail?: NonCancelableEventHandler<{ relatedTarget: Node | null }>; @@ -89,7 +92,8 @@ function InternalInput( disableBrowserAutocorrect, spellcheck, __noBorderRadius, - __noBorder, + __inlineContent, + __inlineContentRef, __startIcon, __onStartIconClick, @@ -340,7 +344,6 @@ function InternalInput( disabled && styles['input-field-disabled'], readOnly && !disabled && styles['input-field-readonly'], __noBorderRadius && styles['input-field-no-border-radius'], - __noBorder && styles['input-field-no-border'], groupedControlClasses )} aria-disabled={disabled || undefined} @@ -356,6 +359,17 @@ function InternalInput( )} + {__inlineContent && ( +
+ {__inlineContent} +
+ )} {mainInput} {hasSuffix && ( <> diff --git a/src/input/styles.scss b/src/input/styles.scss index 479f5f77a6..c4c5914de4 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -260,19 +260,6 @@ $input-tokens: style-api.resolve( border-end-end-radius: awsui.$border-radius-dropdown; } - // Nested so the grouped corner rules win over the field's own radius by specificity, not by source order. - &.input-field-no-border { - min-block-size: unset; - &, - &:hover, - &:focus-within { - border-block-width: 0; - border-inline-width: 0; - box-shadow: none; - background-color: transparent; - } - } - &.grouped { // Unlike other controls, input's padding is rendered by the inner input element and not by its wrapper, // so we should not add extra space to the wrapper for the validation border. @@ -316,11 +303,38 @@ $input-tokens: style-api.resolve( cursor: default; } +// Starts the inline content one small gap after the start icon, which is offset from the outer border edge. +$inline-content-icon-padding: calc( + #{styles.$control-icon-horizontal-offset} + #{awsui.$size-icon-normal} + #{awsui.$space-xxs} - + #{awsui.$border-width-field} +); + +.input-inline-content { + display: flex; + align-items: center; + flex-shrink: 1; + min-inline-size: 0; + column-gap: awsui.$space-xxs; + padding-inline-start: styles.$control-padding-horizontal; + cursor: default; + + &.input-inline-content-has-icon-start { + padding-inline-start: $inline-content-icon-padding; + } + + // The inline content already clears the start icon; the input keeps room to type. + + .input { + padding-inline-start: styles.$control-padding-horizontal; + min-inline-size: 20%; + } +} + // The wider invalid/warning start border is absorbed by the first cell, so the content does not shift. -// The first cell is the prefix when present, otherwise the native input. +// The first cell is the prefix when present, then the inline content, otherwise the native input. .input-field-invalid, .input-field-warning { > .input-prefix, + > .input-inline-content:first-child, > .input:first-child { padding-inline-start: styles.$invalid-control-left-padding; } @@ -332,6 +346,12 @@ $input-tokens: style-api.resolve( ); } + > .input-inline-content-has-icon-start:first-child { + padding-inline-start: calc( + #{$inline-content-icon-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) + ); + } + > .input-prefix, > .input-suffix { max-inline-size: calc(50% - 6rem + (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) / 2); diff --git a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx index 9737bf6d14..8acf5c7770 100644 --- a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx +++ b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx @@ -17,9 +17,10 @@ interface Geometry { container: number | null; token: number; trigger: number; - icon: number; + // Combined width of the inline content and the input, and how much of it the inline content takes. + field?: number; + contentShare?: number; padding?: number; - border?: number; gap?: number; inputMinWidth?: string; } @@ -28,30 +29,31 @@ function setGeometry({ container, token, trigger, - icon, + field = container ?? 0, + contentShare = 0.5, padding = 0, - border = 0, gap = 0, inputMinWidth = '0px', }: Geometry) { mockContainerWidth = container; + const contentWidth = Math.round(field * contentShare); jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { if (this.classList.contains(styles['measurement-token'])) { return token; } - return { trigger, icon }[this.dataset.part as 'trigger' | 'icon'] ?? 0; + const widths = { trigger, content: contentWidth, input: field - contentWidth }; + return widths[this.dataset.part as keyof typeof widths] ?? 0; }); const getComputedStyle = window.getComputedStyle; jest.spyOn(window, 'getComputedStyle').mockImplementation(element => { const style = getComputedStyle(element); - const overrides: Record = { - paddingInlineStart: `${padding}px`, - paddingInlineEnd: `${padding}px`, - borderInlineStartWidth: `${border}px`, - borderInlineEndWidth: `${border}px`, - columnGap: `${gap}px`, - minInlineSize: (element as HTMLElement).dataset.part === 'input' ? inputMinWidth : style.minInlineSize, - }; + const part = (element as HTMLElement).dataset.part; + const overrides: Record = + part === 'content' + ? { paddingInlineStart: `${padding}px`, paddingInlineEnd: `${padding}px`, columnGap: `${gap}px` } + : part === 'input' + ? { minInlineSize: inputMinWidth } + : {}; return new Proxy(style, { get: (target, prop: string) => overrides[prop] ?? (target as any)[prop] }); }); } @@ -72,18 +74,16 @@ function Harness({ }) { const measureRef = React.useRef(null); const measureTriggerRef = React.useRef(null); - const fieldRef = React.useRef(null); - const leadingIconRef = React.useRef(null); - const inputWrapperRef = React.useRef(null); + const inlineContentRef = React.useRef(null); + const inputRef = React.useRef(null); const { containerRef, visibleCount } = useTokenOverflow({ enabled, tokenCount, tokenContentKey: String(tokenCount), measureRef, measureTriggerRef, - fieldRef, - leadingIconRef, - inputWrapperRef, + inlineContentRef, + inputRef, layoutKey, }); return ( @@ -94,9 +94,9 @@ function Harness({ ))} -
- -
+
+
+
{visibleCount}
@@ -108,57 +108,60 @@ const visibleCountFor = (props: { tokenCount: number; enabled?: boolean }) => describe('useTokenOverflow', () => { test('shows all tokens before the container is measured', () => { - setGeometry({ container: null, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: null, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5 })).toBe(5); }); test('shows all tokens when disabled', () => { - setGeometry({ container: 50, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: 50, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5, enabled: false })).toBe(5); }); test('shows all tokens when they fit', () => { - setGeometry({ container: 320, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: 300, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(3); }); test('keeps the trailing tokens that fit next to the overflow trigger', () => { - setGeometry({ container: 260, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: 240, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5 })).toBe(2); }); test('never shows a partially visible token', () => { - setGeometry({ container: 249, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: 229, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5 })).toBe(1); }); test('hides all tokens when none fit next to the trigger', () => { - setGeometry({ container: 120, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: 100, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 2 })).toBe(0); }); - test('accounts for padding and gaps', () => { - setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, padding: 10, gap: 4 }); + test('accounts for the inline content padding and gaps', () => { + setGeometry({ container: 320, token: 100, trigger: 30, padding: 10, gap: 4 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(2); }); - test('accounts for the field borders', () => { - setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, border: 1 }); - expect(visibleCountFor({ tokenCount: 3 })).toBe(2); + test('does not depend on how the field is currently split between tokens and input', () => { + setGeometry({ container: 300, token: 100, trigger: 30, contentShare: 0 }); + expect(visibleCountFor({ tokenCount: 3 })).toBe(3); + jest.restoreAllMocks(); + setGeometry({ container: 300, token: 100, trigger: 30, contentShare: 1 }); + expect(visibleCountFor({ tokenCount: 3 })).toBe(3); }); test('re-measures when the field layout changes', () => { - setGeometry({ container: 320, token: 100, trigger: 30, icon: 20 }); + setGeometry({ container: 300, token: 100, trigger: 30 }); const { container, rerender } = render(); expect(container.querySelector('output')).toHaveTextContent('3'); jest.restoreAllMocks(); - setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, padding: 10 }); + setGeometry({ container: 300, token: 100, trigger: 30, padding: 10 }); rerender(); expect(container.querySelector('output')).toHaveTextContent('2'); }); test('reserves the input minimum width defined in CSS', () => { - setGeometry({ container: 399, token: 100, trigger: 30, icon: 0, inputMinWidth: '25%' }); + setGeometry({ container: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); expect(visibleCountFor({ tokenCount: 3 })).toBe(2); }); }); diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index c279710254..658f68cfc7 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -9,7 +9,6 @@ import { useMergeRefs, useResizeObserver } from '@cloudscape-design/component-to import { AutosuggestProps } from '../../../autosuggest/interfaces'; import { ExpandToViewport } from '../../../dropdown/interfaces'; import InternalDropdown from '../../../dropdown/internal'; -import InternalIcon from '../../../icon/internal'; import { BaseChangeDetail, BaseInputProps, @@ -29,13 +28,11 @@ import { getBreakpointValue } from '../../breakpoints'; import { ResetGroupedControlContext, useGroupedControlContext } from '../../context/control-group-context'; import { useFormFieldContext } from '../../context/form-field-context'; import { fireCancelableEvent, fireNonCancelableEvent } from '../../events'; -import { getGroupedControlClassNames } from '../../grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../../hooks/use-base-component'; import { useIMEComposition } from '../../hooks/use-ime-composition'; import { useListFocusController } from '../../hooks/use-list-focus-controller'; import { KeyCode } from '../../keycode'; import { getDropdownMinWidth } from '../../utils/get-dropdown-min-width'; -import { getInputStylesCss } from '../../utils/input-styles'; import { nodeBelongs } from '../../utils/node-belongs'; import { processAttributes } from '../../utils/with-native-attributes'; import ButtonTrigger from '../button-trigger'; @@ -144,16 +141,14 @@ const AutosuggestInput = React.forwardRef( const preventOpenOnFocusRef = useRef(false); const preventCloseOnBlurRef = useRef(false); - const fieldRef = useRef(null); - const leadingIconRef = useRef(null); - const inputWrapperRef = useRef(null); + const inlineContentRef = useRef(null); const measureRef = useRef(null); const measureTriggerRef = useRef(null); const inlineTokensRef = useRef(null); const [triggerWidth, setTriggerWidth] = useState(null); useResizeObserver( - () => (isTokenMode ? fieldRef.current : inputContainerRef.current), + () => inputContainerRef.current, entry => entry.borderBoxWidth > 0 && setTriggerWidth(entry.borderBoxWidth) ); @@ -167,9 +162,8 @@ const AutosuggestInput = React.forwardRef( tokenContentKey: tokenList.map(token => `${token.value}::${token.icon ? 'icon' : 'no-icon'}`).join('\n'), measureRef, measureTriggerRef, - fieldRef, - leadingIconRef, - inputWrapperRef, + inlineContentRef, + inputRef, layoutKey: [ formFieldContext.invalid, formFieldContext.warning, @@ -194,14 +188,14 @@ const AutosuggestInput = React.forwardRef( showMoreSelector: `.${styles['token-overflow-trigger']}`, fallbackSelector: 'input', }); - const mergedFieldRef = useMergeRefs(fieldRef, focusControllerRef); + const mergedContainerRef = useMergeRefs(inputContainerRef, focusControllerRef); useEffect(() => { if (!tokensOverflowOpen || (hiddenCount > 0 && !disabled)) { return; } setTokensOverflowOpen(false); - if (!disabled && !nodeBelongs(fieldRef.current, document.activeElement)) { + if (!disabled && !nodeBelongs(inputContainerRef.current, document.activeElement)) { setNextFocusTokenIndex(0); } }, [tokensOverflowOpen, hiddenCount, disabled]); @@ -372,7 +366,6 @@ const AutosuggestInput = React.forwardRef( 'aria-owns': open ? ariaControls : undefined, 'aria-label': ariaLabel, 'aria-activedescendant': ariaActivedescendant, - ...(isTokenMode && formFieldContext.invalid ? { 'aria-invalid': 'true' as const } : {}), }; // Closes dropdown when outside click is detected. @@ -386,8 +379,7 @@ const AutosuggestInput = React.forwardRef( if ( !nodeBelongs(inputContainerRef.current, event.target) && !nodeBelongs(dropdownContentRef.current, event.target) && - !nodeBelongs(dropdownFooterRef.current, event.target) && - !nodeBelongs(fieldRef.current, event.target) + !nodeBelongs(dropdownFooterRef.current, event.target) ) { closeDropdown(); } @@ -427,75 +419,36 @@ const AutosuggestInput = React.forwardRef( ariaRequired, clearAriaLabel, ref: inputRef, - __internalRootRef: inputContainerRef, + __internalRootRef: mergedContainerRef, autoComplete: false, nativeInputAttributes: processAttributes(nativeAttributes, 'Autosuggest', nativeInputAttributes), __skipNativeAttributesWarnings: Object.keys(nativeAttributes), }; - const tokenField = isTokenMode && ( - -
- - - - {tokenList.length > 0 && ( - { - if (isOpen) { - closeDropdown(); - } - setTokensOverflowOpen(isOpen); - }} - onDismiss={removeToken} - onFocusEnter={() => (preventOpenOnFocusRef.current = true)} - onNavigateToInput={() => { - setTokensOverflowOpen(false); - inputRef.current?.focus(); - }} - /> - )} -
- { - inputRef.current?.focus(); - clearValue(); - }, - } - : {})} - {...formFieldContext} - invalid={false} - warning={false} - /> -
-
-
+ const inlineTokens = tokenList.length > 0 && ( + { + if (isOpen) { + closeDropdown(); + } + setTokensOverflowOpen(isOpen); + }} + onDismiss={removeToken} + onFocusEnter={() => (preventOpenOnFocusRef.current = true)} + onNavigateToInput={() => { + setTokensOverflowOpen(false); + inputRef.current?.focus(); + }} + /> ); const dropdown = ( @@ -506,7 +459,14 @@ const AutosuggestInput = React.forwardRef( onFocus={handleFocus} onBlur={handleBlur} trigger={ - tokenField || + } onMouseDown={handleDropdownMouseDown} open={open && (!!dropdownContent || !!dropdownFooter)} diff --git a/src/internal/components/autosuggest-input/styles.scss b/src/internal/components/autosuggest-input/styles.scss index a20aaf4e14..703a384161 100644 --- a/src/internal/components/autosuggest-input/styles.scss +++ b/src/internal/components/autosuggest-input/styles.scss @@ -5,8 +5,6 @@ @use '../../../internal/styles/tokens' as awsui; @use '../../../internal/styles' as styles; -@use '../../../internal/styles/foundation' as foundation; -@use '../../../internal/generated/custom-css-properties/index.scss' as custom-props; .root { // Used in test-utils. @@ -26,81 +24,6 @@ inline-size: 100%; } -.token-field { - display: flex; - align-items: center; - flex-wrap: nowrap; - inline-size: 100%; - box-sizing: border-box; - min-block-size: awsui.$size-vertical-input; - padding-inline-start: styles.$control-padding-horizontal; - padding-inline-end: 0; - column-gap: awsui.$space-xxs; - @include styles.logical-radius(styles.$control-border-radius); - @include styles.input-default-styles; - - &:hover { - @include styles.input-hover-styles; - } - - &:focus-within { - background-color: var(#{custom-props.$styleBackgroundFocus}, awsui.$color-background-input-default); - @include styles.input-focus-styles; - } - - &.token-field-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}); - } - - &.token-field-readonly { - box-shadow: var(#{custom-props.$styleBoxShadowReadonly}); - @include styles.input-readonly-styles; - } - - &.token-field-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) - ); - - &:focus-within { - box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid); - } - } - - &.token-field-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) - ); - - &:focus-within { - box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light-invalid); - } - } - - &.grouped { - @include styles.grouped-control; - &.token-field-disabled { - @include styles.grouped-control-disabled; - } - } -} - -.token-field-icon { - display: flex; - align-items: center; - flex-shrink: 0; -} - .token-list { @include styles.styles-reset-ul; display: flex; @@ -119,13 +42,6 @@ display: contents; } -.token-input-wrapper { - flex: 1 1 0; - min-inline-size: 20%; - overflow: hidden; - margin-inline-start: calc(-1 * #{awsui.$space-xxs}); -} - .token-overflow { flex-shrink: 0; } diff --git a/src/internal/components/autosuggest-input/use-token-overflow.ts b/src/internal/components/autosuggest-input/use-token-overflow.ts index c70df2340b..b2580341b5 100644 --- a/src/internal/components/autosuggest-input/use-token-overflow.ts +++ b/src/internal/components/autosuggest-input/use-token-overflow.ts @@ -13,9 +13,8 @@ interface UseTokenOverflowOptions { tokenContentKey: string; measureRef: React.RefObject; measureTriggerRef: React.RefObject; - fieldRef: React.RefObject; - leadingIconRef: React.RefObject; - inputWrapperRef: React.RefObject; + inlineContentRef: React.RefObject; + inputRef: React.RefObject; layoutKey: string; } @@ -33,40 +32,35 @@ export function useTokenOverflow({ tokenContentKey, measureRef, measureTriggerRef, - fieldRef, - leadingIconRef, - inputWrapperRef, + inlineContentRef, + inputRef, layoutKey, }: UseTokenOverflowOptions): UseTokenOverflowResult { const [containerWidth, containerRef] = useContainerQuery(entry => entry.borderBoxWidth); const [visibleCount, setVisibleCount] = useState(tokenCount); useLayoutEffect(() => { - const field = fieldRef.current; + const content = inlineContentRef.current; + const input = inputRef.current; const measure = measureRef.current; - if (!enabled || !field || !measure || !containerWidth) { + if (!enabled || !content || !input || !measure || !containerWidth) { setVisibleCount(tokenCount); return; } measure.inert = true; - const fieldStyle = getComputedStyle(field); - const paddingInline = parseFloat(fieldStyle.paddingInlineStart) + parseFloat(fieldStyle.paddingInlineEnd); - const borderInline = - (parseFloat(fieldStyle.borderInlineStartWidth) || 0) + (parseFloat(fieldStyle.borderInlineEndWidth) || 0); - const gap = parseFloat(fieldStyle.columnGap) || 0; - const contentWidth = containerWidth - paddingInline - borderInline; - const inputMinWidth = resolveLength( - inputWrapperRef.current ? getComputedStyle(inputWrapperRef.current).minInlineSize : '', - contentWidth - ); - const iconWidth = leadingIconRef.current?.offsetWidth ?? 0; + // The inline content and the input share the field's content box, whatever the tokens currently take. + const fieldWidth = content.offsetWidth + input.offsetWidth; + const contentStyle = getComputedStyle(content); + const paddingInline = parseFloat(contentStyle.paddingInlineStart) + parseFloat(contentStyle.paddingInlineEnd); + const gap = parseFloat(contentStyle.columnGap) || 0; + const inputMinWidth = resolveLength(getComputedStyle(input).minInlineSize, fieldWidth); const triggerWidth = measureTriggerRef.current?.offsetWidth ?? 0; const tokenWidths = Array.from(measure.querySelectorAll(`.${styles['measurement-token']}`)).map( el => el.offsetWidth ); - const budget = contentWidth - iconWidth - inputMinWidth - 2 * gap; + const budget = fieldWidth - paddingInline - inputMinWidth; const totalWidth = tokenWidths.reduce((sum, width) => sum + width + gap, -gap); setVisibleCount( totalWidth <= budget ? tokenWidths.length : countFittingFromEnd(tokenWidths, budget - triggerWidth - gap, gap) @@ -77,11 +71,10 @@ export function useTokenOverflow({ tokenContentKey, containerWidth, layoutKey, - fieldRef, measureRef, measureTriggerRef, - leadingIconRef, - inputWrapperRef, + inlineContentRef, + inputRef, ]); return { containerRef, visibleCount: Math.min(visibleCount, tokenCount) }; From 6111c36f906338ccc91329db3beb34fa79c0b0c9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 30 Oct 2026 10:39:33 +0100 Subject: [PATCH 2/9] Refactor --- src/input/internal.tsx | 3 -- src/input/styles.scss | 4 -- .../__tests__/use-token-overflow.test.tsx | 38 ++++++++----------- .../components/autosuggest-input/index.tsx | 6 +-- .../autosuggest-input/inline-tokens.tsx | 6 ++- .../components/autosuggest-input/styles.scss | 6 ++- .../autosuggest-input/use-token-overflow.ts | 22 +++++------ 7 files changed, 39 insertions(+), 46 deletions(-) diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 824534c1e2..d75bb45ab7 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -54,7 +54,6 @@ export interface InternalInputProps // Rendered inside the field, before the native input. __inlineContent?: React.ReactNode; - __inlineContentRef?: React.Ref; __onDelayedInput?: NonCancelableEventHandler; __onBlurWithDetail?: NonCancelableEventHandler<{ relatedTarget: Node | null }>; @@ -93,7 +92,6 @@ function InternalInput( spellcheck, __noBorderRadius, __inlineContent, - __inlineContentRef, __startIcon, __onStartIconClick, @@ -361,7 +359,6 @@ function InternalInput( )} {__inlineContent && (
= - part === 'content' - ? { paddingInlineStart: `${padding}px`, paddingInlineEnd: `${padding}px`, columnGap: `${gap}px` } - : part === 'input' - ? { minInlineSize: inputMinWidth } - : {}; + part === 'tokens' ? { columnGap: `${gap}px` } : part === 'input' ? { minInlineSize: inputMinWidth } : {}; return new Proxy(style, { get: (target, prop: string) => overrides[prop] ?? (target as any)[prop] }); }); } @@ -74,7 +68,7 @@ function Harness({ }) { const measureRef = React.useRef(null); const measureTriggerRef = React.useRef(null); - const inlineContentRef = React.useRef(null); + const tokensRef = React.useRef(null); const inputRef = React.useRef(null); const { containerRef, visibleCount } = useTokenOverflow({ enabled, @@ -82,7 +76,7 @@ function Harness({ tokenContentKey: String(tokenCount), measureRef, measureTriggerRef, - inlineContentRef, + tokensRef, inputRef, layoutKey, }); @@ -95,7 +89,7 @@ function Harness({
-
+
{visibleCount} @@ -137,16 +131,16 @@ describe('useTokenOverflow', () => { expect(visibleCountFor({ tokenCount: 2 })).toBe(0); }); - test('accounts for the inline content padding and gaps', () => { - setGeometry({ container: 320, token: 100, trigger: 30, padding: 10, gap: 4 }); + test('accounts for the gaps between tokens', () => { + setGeometry({ container: 300, token: 100, trigger: 30, gap: 4 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(2); }); test('does not depend on how the field is currently split between tokens and input', () => { - setGeometry({ container: 300, token: 100, trigger: 30, contentShare: 0 }); + setGeometry({ container: 300, token: 100, trigger: 30, tokensShare: 0 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(3); jest.restoreAllMocks(); - setGeometry({ container: 300, token: 100, trigger: 30, contentShare: 1 }); + setGeometry({ container: 300, token: 100, trigger: 30, tokensShare: 1 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(3); }); @@ -155,7 +149,7 @@ describe('useTokenOverflow', () => { const { container, rerender } = render(); expect(container.querySelector('output')).toHaveTextContent('3'); jest.restoreAllMocks(); - setGeometry({ container: 300, token: 100, trigger: 30, padding: 10 }); + setGeometry({ container: 300, token: 100, trigger: 30, available: 280 }); rerender(); expect(container.querySelector('output')).toHaveTextContent('2'); }); diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 658f68cfc7..1e3ed37d11 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -141,7 +141,7 @@ const AutosuggestInput = React.forwardRef( const preventOpenOnFocusRef = useRef(false); const preventCloseOnBlurRef = useRef(false); - const inlineContentRef = useRef(null); + const inlineTokensRootRef = useRef(null); const measureRef = useRef(null); const measureTriggerRef = useRef(null); const inlineTokensRef = useRef(null); @@ -162,7 +162,7 @@ const AutosuggestInput = React.forwardRef( tokenContentKey: tokenList.map(token => `${token.value}::${token.icon ? 'icon' : 'no-icon'}`).join('\n'), measureRef, measureTriggerRef, - inlineContentRef, + tokensRef: inlineTokensRootRef, inputRef, layoutKey: [ formFieldContext.invalid, @@ -428,6 +428,7 @@ const AutosuggestInput = React.forwardRef( const inlineTokens = tokenList.length > 0 && ( } onMouseDown={handleDropdownMouseDown} diff --git a/src/internal/components/autosuggest-input/inline-tokens.tsx b/src/internal/components/autosuggest-input/inline-tokens.tsx index 73c22b8241..1b17273c3b 100644 --- a/src/internal/components/autosuggest-input/inline-tokens.tsx +++ b/src/internal/components/autosuggest-input/inline-tokens.tsx @@ -4,6 +4,7 @@ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; +import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; import { SingleTabStopNavigationAPI, SingleTabStopNavigationProvider, @@ -32,6 +33,7 @@ export interface InlineTokensProps { onDismiss: (index: number, source: 'inline' | 'overflow') => void; onFocusEnter: () => void; onNavigateToInput: () => void; + rootRef?: React.Ref; } export interface InlineTokensRef { @@ -53,10 +55,12 @@ export const InlineTokens = forwardRef( onDismiss, onFocusEnter, onNavigateToInput, + rootRef: externalRootRef, }: InlineTokensProps, ref: React.Ref ) => { const rootRef = useRef(null); + const mergedRootRef = useMergeRefs(rootRef, externalRootRef); const navigationAPI = useRef(null); const lastFocusedRef = useRef(null); @@ -125,7 +129,7 @@ export const InlineTokens = forwardRef( }; return ( -
+
; measureTriggerRef: React.RefObject; - inlineContentRef: React.RefObject; + tokensRef: React.RefObject; inputRef: React.RefObject; layoutKey: string; } @@ -32,7 +32,7 @@ export function useTokenOverflow({ tokenContentKey, measureRef, measureTriggerRef, - inlineContentRef, + tokensRef, inputRef, layoutKey, }: UseTokenOverflowOptions): UseTokenOverflowResult { @@ -40,27 +40,25 @@ export function useTokenOverflow({ const [visibleCount, setVisibleCount] = useState(tokenCount); useLayoutEffect(() => { - const content = inlineContentRef.current; + const tokens = tokensRef.current; const input = inputRef.current; const measure = measureRef.current; - if (!enabled || !content || !input || !measure || !containerWidth) { + if (!enabled || !tokens || !input || !measure || !containerWidth) { setVisibleCount(tokenCount); return; } measure.inert = true; - // The inline content and the input share the field's content box, whatever the tokens currently take. - const fieldWidth = content.offsetWidth + input.offsetWidth; - const contentStyle = getComputedStyle(content); - const paddingInline = parseFloat(contentStyle.paddingInlineStart) + parseFloat(contentStyle.paddingInlineEnd); - const gap = parseFloat(contentStyle.columnGap) || 0; - const inputMinWidth = resolveLength(getComputedStyle(input).minInlineSize, fieldWidth); + // The tokens and the input share the available space, whatever the tokens currently take. + const availableWidth = tokens.offsetWidth + input.offsetWidth; + const gap = parseFloat(getComputedStyle(tokens).columnGap) || 0; + const inputMinWidth = resolveLength(getComputedStyle(input).minInlineSize, containerWidth); const triggerWidth = measureTriggerRef.current?.offsetWidth ?? 0; const tokenWidths = Array.from(measure.querySelectorAll(`.${styles['measurement-token']}`)).map( el => el.offsetWidth ); - const budget = fieldWidth - paddingInline - inputMinWidth; + const budget = availableWidth - inputMinWidth; const totalWidth = tokenWidths.reduce((sum, width) => sum + width + gap, -gap); setVisibleCount( totalWidth <= budget ? tokenWidths.length : countFittingFromEnd(tokenWidths, budget - triggerWidth - gap, gap) @@ -73,7 +71,7 @@ export function useTokenOverflow({ layoutKey, measureRef, measureTriggerRef, - inlineContentRef, + tokensRef, inputRef, ]); From 2b39ac58f3841ad7ae24f4bf046815d711512b4e Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 30 Oct 2026 13:30:03 +0100 Subject: [PATCH 3/9] Refactor and cleanup --- .../__tests__/autosuggest-token-mode.test.tsx | 6 +-- src/input/internal.tsx | 10 ++-- src/input/styles.scss | 17 +++---- .../__tests__/use-token-overflow.test.tsx | 47 +++++++++---------- .../components/autosuggest-input/index.tsx | 7 ++- .../components/autosuggest-input/styles.scss | 6 --- .../autosuggest-input/use-token-overflow.ts | 30 +++--------- 7 files changed, 46 insertions(+), 77 deletions(-) diff --git a/src/autosuggest/__tests__/autosuggest-token-mode.test.tsx b/src/autosuggest/__tests__/autosuggest-token-mode.test.tsx index 6481f2fe21..03b6f2f7f4 100644 --- a/src/autosuggest/__tests__/autosuggest-token-mode.test.tsx +++ b/src/autosuggest/__tests__/autosuggest-token-mode.test.tsx @@ -17,10 +17,8 @@ import createWrapper from '../../../lib/components/test-utils/dom'; let mockVisibleCount: number | null = null; jest.mock('../../../lib/components/internal/components/autosuggest-input/use-token-overflow', () => ({ - useTokenOverflow: ({ tokenCount }: { tokenCount: number }) => ({ - containerRef: () => {}, - visibleCount: mockVisibleCount === null ? tokenCount : Math.min(mockVisibleCount, tokenCount), - }), + useTokenOverflow: ({ tokenCount }: { tokenCount: number }) => + mockVisibleCount === null ? tokenCount : Math.min(mockVisibleCount, tokenCount), })); jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ diff --git a/src/input/internal.tsx b/src/input/internal.tsx index d75bb45ab7..fae57a37e2 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -52,7 +52,7 @@ export interface InternalInputProps __noBorderRadius?: boolean; - // Rendered inside the field, before the native input. + // Rendered inside the field, between the start icon and the native input. __inlineContent?: React.ReactNode; __onDelayedInput?: NonCancelableEventHandler; @@ -342,6 +342,7 @@ function InternalInput( disabled && styles['input-field-disabled'], readOnly && !disabled && styles['input-field-readonly'], __noBorderRadius && styles['input-field-no-border-radius'], + !!__inlineContent && styles['input-field-has-inline-content'], groupedControlClasses )} aria-disabled={disabled || undefined} @@ -358,12 +359,7 @@ function InternalInput( )} {__inlineContent && ( -
+
{__inlineContent}
)} diff --git a/src/input/styles.scss b/src/input/styles.scss index 26d0a898c8..585a360fef 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -177,7 +177,6 @@ $input-tokens: style-api.resolve( inline-size: 100%; box-sizing: border-box; cursor: text; - overflow: clip; 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; @@ -194,6 +193,11 @@ $input-tokens: style-api.resolve( @include styles.input-hover-styles; } + // Inline content can open popovers (e.g. the token overflow dropdown) that must not be clipped. + &:not(.input-field-has-inline-content) { + overflow: clip; + } + &.input-field-readonly { color: var( #{custom-props.$styleColorReadonly}, @@ -312,11 +316,9 @@ $inline-content-icon-padding: calc( .input-inline-content { display: flex; min-inline-size: 0; - padding-inline-start: styles.$control-padding-horizontal; - - &.input-inline-content-has-icon-start { - padding-inline-start: $inline-content-icon-padding; - } + padding-inline-start: $inline-content-icon-padding; + // Inline content is not editable text, so it does not show the field's text cursor. + cursor: default; // The inline content already clears the start icon; the input keeps room to type. + .input { @@ -330,7 +332,6 @@ $inline-content-icon-padding: calc( .input-field-invalid, .input-field-warning { > .input-prefix, - > .input-inline-content:first-child, > .input:first-child { padding-inline-start: styles.$invalid-control-left-padding; } @@ -342,7 +343,7 @@ $inline-content-icon-padding: calc( ); } - > .input-inline-content-has-icon-start:first-child { + > .input-inline-content:first-child { padding-inline-start: calc( #{$inline-content-icon-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) ); diff --git a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx index 24a909e288..4d0fb66cfb 100644 --- a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx +++ b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx @@ -7,14 +7,10 @@ import { useTokenOverflow } from '../../../../../lib/components/internal/compone import styles from '../../../../../lib/components/internal/components/autosuggest-input/styles.css.js'; -let mockContainerWidth: number | null = null; -jest.mock('@cloudscape-design/component-toolkit', () => ({ - ...jest.requireActual('@cloudscape-design/component-toolkit'), - useContainerQuery: () => [mockContainerWidth, () => {}], -})); +let mockFieldWidth: number | null = null; interface Geometry { - container: number | null; + field: number | null; token: number; trigger: number; // Combined width of the tokens and the input, and how much of it the tokens take. @@ -25,15 +21,15 @@ interface Geometry { } function setGeometry({ - container, + field, token, trigger, - available = container ?? 0, + available = field ?? 0, tokensShare = 0.5, gap = 0, inputMinWidth = '0px', }: Geometry) { - mockContainerWidth = container; + mockFieldWidth = field; const tokensWidth = Math.round(available * tokensShare); jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { if (this.classList.contains(styles['measurement-token'])) { @@ -54,7 +50,7 @@ function setGeometry({ afterEach(() => { jest.restoreAllMocks(); - mockContainerWidth = null; + mockFieldWidth = null; }); function Harness({ @@ -70,7 +66,7 @@ function Harness({ const measureTriggerRef = React.useRef(null); const tokensRef = React.useRef(null); const inputRef = React.useRef(null); - const { containerRef, visibleCount } = useTokenOverflow({ + const visibleCount = useTokenOverflow({ enabled, tokenCount, tokenContentKey: String(tokenCount), @@ -78,10 +74,11 @@ function Harness({ measureTriggerRef, tokensRef, inputRef, + fieldWidth: mockFieldWidth, layoutKey, }); return ( -
+
{Array.from({ length: tokenCount }, (_, i) => ( @@ -101,61 +98,61 @@ const visibleCountFor = (props: { tokenCount: number; enabled?: boolean }) => Number(render().container.querySelector('output')!.textContent); describe('useTokenOverflow', () => { - test('shows all tokens before the container is measured', () => { - setGeometry({ container: null, token: 100, trigger: 30 }); + test('shows all tokens before the field is measured', () => { + setGeometry({ field: null, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5 })).toBe(5); }); test('shows all tokens when disabled', () => { - setGeometry({ container: 50, token: 100, trigger: 30 }); + setGeometry({ field: 50, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5, enabled: false })).toBe(5); }); test('shows all tokens when they fit', () => { - setGeometry({ container: 300, token: 100, trigger: 30 }); + setGeometry({ field: 300, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(3); }); test('keeps the trailing tokens that fit next to the overflow trigger', () => { - setGeometry({ container: 240, token: 100, trigger: 30 }); + setGeometry({ field: 240, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5 })).toBe(2); }); test('never shows a partially visible token', () => { - setGeometry({ container: 229, token: 100, trigger: 30 }); + setGeometry({ field: 229, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 5 })).toBe(1); }); test('hides all tokens when none fit next to the trigger', () => { - setGeometry({ container: 100, token: 100, trigger: 30 }); + setGeometry({ field: 100, token: 100, trigger: 30 }); expect(visibleCountFor({ tokenCount: 2 })).toBe(0); }); test('accounts for the gaps between tokens', () => { - setGeometry({ container: 300, token: 100, trigger: 30, gap: 4 }); + setGeometry({ field: 300, token: 100, trigger: 30, gap: 4 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(2); }); test('does not depend on how the field is currently split between tokens and input', () => { - setGeometry({ container: 300, token: 100, trigger: 30, tokensShare: 0 }); + setGeometry({ field: 300, token: 100, trigger: 30, tokensShare: 0 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(3); jest.restoreAllMocks(); - setGeometry({ container: 300, token: 100, trigger: 30, tokensShare: 1 }); + setGeometry({ field: 300, token: 100, trigger: 30, tokensShare: 1 }); expect(visibleCountFor({ tokenCount: 3 })).toBe(3); }); test('re-measures when the field layout changes', () => { - setGeometry({ container: 300, token: 100, trigger: 30 }); + setGeometry({ field: 300, token: 100, trigger: 30 }); const { container, rerender } = render(); expect(container.querySelector('output')).toHaveTextContent('3'); jest.restoreAllMocks(); - setGeometry({ container: 300, token: 100, trigger: 30, available: 280 }); + setGeometry({ field: 300, token: 100, trigger: 30, available: 280 }); rerender(); expect(container.querySelector('output')).toHaveTextContent('2'); }); test('reserves the input minimum width defined in CSS', () => { - setGeometry({ container: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); + setGeometry({ field: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); expect(visibleCountFor({ tokenCount: 3 })).toBe(2); }); }); diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 1e3ed37d11..1994391c83 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -156,7 +156,7 @@ const AutosuggestInput = React.forwardRef( const [tokensOverflowOpen, setTokensOverflowOpen] = useState(false); const [announcement, setAnnouncement] = useState(''); - const { containerRef, visibleCount } = useTokenOverflow({ + const visibleCount = useTokenOverflow({ enabled: isTokenMode, tokenCount: tokenList.length, tokenContentKey: tokenList.map(token => `${token.value}::${token.icon ? 'icon' : 'no-icon'}`).join('\n'), @@ -164,6 +164,7 @@ const AutosuggestInput = React.forwardRef( measureTriggerRef, tokensRef: inlineTokensRootRef, inputRef, + fieldWidth: triggerWidth, layoutKey: [ formFieldContext.invalid, formFieldContext.warning, @@ -515,9 +516,7 @@ const AutosuggestInput = React.forwardRef( {`+${tokenList.length}`}
-
- {dropdown} -
+ {dropdown} ) : ( dropdown diff --git a/src/internal/components/autosuggest-input/styles.scss b/src/internal/components/autosuggest-input/styles.scss index 687ed35286..ddf8ab1969 100644 --- a/src/internal/components/autosuggest-input/styles.scss +++ b/src/internal/components/autosuggest-input/styles.scss @@ -19,11 +19,6 @@ display: contents; } -.token-field-container { - position: relative; - inline-size: 100%; -} - .token-list { @include styles.styles-reset-ul; display: flex; @@ -39,7 +34,6 @@ align-items: center; column-gap: awsui.$space-xxs; min-inline-size: 0; - cursor: default; } .token-list-item { diff --git a/src/internal/components/autosuggest-input/use-token-overflow.ts b/src/internal/components/autosuggest-input/use-token-overflow.ts index 6553d4ae8c..9e165cca6a 100644 --- a/src/internal/components/autosuggest-input/use-token-overflow.ts +++ b/src/internal/components/autosuggest-input/use-token-overflow.ts @@ -3,8 +3,6 @@ import { useLayoutEffect, useState } from 'react'; -import { useContainerQuery } from '@cloudscape-design/component-toolkit'; - import styles from './styles.css.js'; interface UseTokenOverflowOptions { @@ -15,14 +13,10 @@ interface UseTokenOverflowOptions { measureTriggerRef: React.RefObject; tokensRef: React.RefObject; inputRef: React.RefObject; + fieldWidth: number | null; layoutKey: string; } -interface UseTokenOverflowResult { - containerRef: React.Ref; - visibleCount: number; -} - /** * Returns how many trailing tokens fit fully next to the input; the rest collapse into the overflow trigger. */ @@ -34,16 +28,16 @@ export function useTokenOverflow({ measureTriggerRef, tokensRef, inputRef, + fieldWidth, layoutKey, -}: UseTokenOverflowOptions): UseTokenOverflowResult { - const [containerWidth, containerRef] = useContainerQuery(entry => entry.borderBoxWidth); +}: UseTokenOverflowOptions): number { const [visibleCount, setVisibleCount] = useState(tokenCount); useLayoutEffect(() => { const tokens = tokensRef.current; const input = inputRef.current; const measure = measureRef.current; - if (!enabled || !tokens || !input || !measure || !containerWidth) { + if (!enabled || !tokens || !input || !measure || !fieldWidth) { setVisibleCount(tokenCount); return; } @@ -52,7 +46,7 @@ export function useTokenOverflow({ // The tokens and the input share the available space, whatever the tokens currently take. const availableWidth = tokens.offsetWidth + input.offsetWidth; const gap = parseFloat(getComputedStyle(tokens).columnGap) || 0; - const inputMinWidth = resolveLength(getComputedStyle(input).minInlineSize, containerWidth); + const inputMinWidth = resolveLength(getComputedStyle(input).minInlineSize, fieldWidth); const triggerWidth = measureTriggerRef.current?.offsetWidth ?? 0; const tokenWidths = Array.from(measure.querySelectorAll(`.${styles['measurement-token']}`)).map( el => el.offsetWidth @@ -63,19 +57,9 @@ export function useTokenOverflow({ setVisibleCount( totalWidth <= budget ? tokenWidths.length : countFittingFromEnd(tokenWidths, budget - triggerWidth - gap, gap) ); - }, [ - enabled, - tokenCount, - tokenContentKey, - containerWidth, - layoutKey, - measureRef, - measureTriggerRef, - tokensRef, - inputRef, - ]); + }, [enabled, tokenCount, tokenContentKey, fieldWidth, layoutKey, measureRef, measureTriggerRef, tokensRef, inputRef]); - return { containerRef, visibleCount: Math.min(visibleCount, tokenCount) }; + return Math.min(visibleCount, tokenCount); } function countFittingFromEnd(widths: number[], budget: number, gap: number) { From 66b44e9ac19a85c94c317b94ed0d8665e1a56ff8 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 30 Oct 2026 17:26:42 +0100 Subject: [PATCH 4/9] Refactor --- src/input/styles.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/input/styles.scss b/src/input/styles.scss index 585a360fef..5437083c85 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -336,16 +336,16 @@ $inline-content-icon-padding: calc( padding-inline-start: styles.$invalid-control-left-padding; } - > .input-has-icon-start:first-child { + > .input-inline-content:first-child { padding-inline-start: calc( - #{styles.$control-icon-horizontal-padding} - - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) + #{$inline-content-icon-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) ); } - > .input-inline-content:first-child { + > .input-has-icon-start:first-child { padding-inline-start: calc( - #{$inline-content-icon-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) + #{styles.$control-icon-horizontal-padding} - + (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) ); } From 91ccb4ea132229aee84bf5541cd1f7be718822c6 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 2 Nov 2026 14:44:44 +0100 Subject: [PATCH 5/9] Measure tokens width also when it is 0 --- src/autosuggest/__tests__/autosuggest.test.tsx | 2 -- .../__tests__/use-token-overflow.test.tsx | 10 ++++++++++ src/internal/components/autosuggest-input/index.tsx | 8 ++++---- src/multiselect/__tests__/multiselect.test.tsx | 7 +++---- src/multiselect/internal.tsx | 2 +- src/select/__tests__/select.test.tsx | 1 - src/select/internal.tsx | 2 +- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/autosuggest/__tests__/autosuggest.test.tsx b/src/autosuggest/__tests__/autosuggest.test.tsx index b0634c2e3f..a060b5b395 100644 --- a/src/autosuggest/__tests__/autosuggest.test.tsx +++ b/src/autosuggest/__tests__/autosuggest.test.tsx @@ -674,7 +674,6 @@ describe('native attributes', () => { it('warns about overriding autosuggest-specific attributes', () => { renderAutosuggest(); - expect(warnOnce).toHaveBeenCalledTimes(1); expect(warnOnce).toHaveBeenCalledWith( 'Autosuggest', 'Overriding native attribute [aria-autocomplete] which has a Cloudscape-provided value' @@ -685,7 +684,6 @@ describe('native attributes', () => { renderAutosuggest( ); - expect(warnOnce).toHaveBeenCalledTimes(1); expect(warnOnce).toHaveBeenCalledWith( 'Input', 'Overriding native attribute [aria-required] which has a Cloudscape-provided value' diff --git a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx index 4d0fb66cfb..9c8337ec99 100644 --- a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx +++ b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx @@ -151,6 +151,16 @@ describe('useTokenOverflow', () => { expect(container.querySelector('output')).toHaveTextContent('2'); }); + test('shows all tokens while the field is hidden and re-measures when it is shown again', () => { + setGeometry({ field: 0, token: 0, trigger: 0 }); + const { container, rerender } = render(); + expect(container.querySelector('output')).toHaveTextContent('5'); + jest.restoreAllMocks(); + setGeometry({ field: 240, token: 100, trigger: 30 }); + rerender(); + expect(container.querySelector('output')).toHaveTextContent('2'); + }); + test('reserves the input minimum width defined in CSS', () => { setGeometry({ field: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); expect(visibleCountFor({ tokenCount: 3 })).toBe(2); diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 1994391c83..6cf46546c7 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -146,10 +146,10 @@ const AutosuggestInput = React.forwardRef( const measureTriggerRef = useRef(null); const inlineTokensRef = useRef(null); - const [triggerWidth, setTriggerWidth] = useState(null); + const [fieldWidth, setFieldWidth] = useState(null); useResizeObserver( () => inputContainerRef.current, - entry => entry.borderBoxWidth > 0 && setTriggerWidth(entry.borderBoxWidth) + entry => setFieldWidth(entry.borderBoxWidth) ); const [open, setOpen] = useState(false); @@ -164,7 +164,7 @@ const AutosuggestInput = React.forwardRef( measureTriggerRef, tokensRef: inlineTokensRootRef, inputRef, - fieldWidth: triggerWidth, + fieldWidth, layoutKey: [ formFieldContext.invalid, formFieldContext.warning, @@ -455,7 +455,7 @@ const AutosuggestInput = React.forwardRef( const dropdown = ( { expect(getDropdownMinWidthSpy).toHaveBeenCalledWith(expect.objectContaining({ triggerWidth: 300 })); }); - test('does not set triggerWidth when borderBoxWidth is 0', () => { + test('sets triggerWidth to 0 when the trigger is hidden', () => { renderMultiselect(); - getDropdownMinWidthSpy.mockClear(); + fireResize(300); fireResize(0); - // borderBoxWidth === 0 short-circuits the callback so triggerWidth stays null and no re-render occurs - expect(getDropdownMinWidthSpy).not.toHaveBeenCalled(); + expect(getDropdownMinWidthSpy).toHaveBeenLastCalledWith(expect.objectContaining({ triggerWidth: 0 })); }); }); diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index 4f31667b5a..d90e9317ce 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -149,7 +149,7 @@ const InternalMultiselect = React.forwardRef( const [triggerWidth, setTriggerWidth] = useState(null); useResizeObserver( () => triggerRef.current, - entry => entry.borderBoxWidth > 0 && setTriggerWidth(entry.borderBoxWidth) + entry => setTriggerWidth(entry.borderBoxWidth) ); const trigger = ( diff --git a/src/select/__tests__/select.test.tsx b/src/select/__tests__/select.test.tsx index d1f9a7ea59..4cce1efbac 100644 --- a/src/select/__tests__/select.test.tsx +++ b/src/select/__tests__/select.test.tsx @@ -220,7 +220,6 @@ describe.each([false, true])('expandToViewport=%s', expandToViewport => { recoveryText: 'Retry', statusType: 'error', }); - expect(warnOnce).toHaveBeenCalledTimes(1); expect(warnOnce).toHaveBeenCalledWith( 'Select', '`onLoadItems` must be provided for `recoveryText` to be displayed.' diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 337233b338..8fd744fd84 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -133,7 +133,7 @@ const InternalSelect = React.forwardRef( const [triggerWidth, setTriggerWidth] = useState(null); useResizeObserver( () => triggerRef.current, - entry => entry.borderBoxWidth > 0 && setTriggerWidth(entry.borderBoxWidth) + entry => setTriggerWidth(entry.borderBoxWidth) ); const selfControlId = useUniqueId('trigger'); From 6105f4eb32574fb94c24175bb0ed2629fba928d9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 2 Nov 2026 15:01:05 +0100 Subject: [PATCH 6/9] Refactor --- src/internal/components/autosuggest-input/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 6cf46546c7..e4bc04d32e 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -146,10 +146,10 @@ const AutosuggestInput = React.forwardRef( const measureTriggerRef = useRef(null); const inlineTokensRef = useRef(null); - const [fieldWidth, setFieldWidth] = useState(null); + const [triggerWidth, setTriggerWidth] = useState(null); useResizeObserver( () => inputContainerRef.current, - entry => setFieldWidth(entry.borderBoxWidth) + entry => setTriggerWidth(entry.borderBoxWidth) ); const [open, setOpen] = useState(false); @@ -164,7 +164,7 @@ const AutosuggestInput = React.forwardRef( measureTriggerRef, tokensRef: inlineTokensRootRef, inputRef, - fieldWidth, + fieldWidth: triggerWidth, layoutKey: [ formFieldContext.invalid, formFieldContext.warning, @@ -455,7 +455,7 @@ const AutosuggestInput = React.forwardRef( const dropdown = ( Date: Mon, 2 Nov 2026 15:02:54 +0100 Subject: [PATCH 7/9] Simplify tests --- .../__tests__/use-token-overflow.test.tsx | 88 ++++++++----------- 1 file changed, 37 insertions(+), 51 deletions(-) diff --git a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx index 9c8337ec99..f6833401cc 100644 --- a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx +++ b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx @@ -7,29 +7,17 @@ import { useTokenOverflow } from '../../../../../lib/components/internal/compone import styles from '../../../../../lib/components/internal/components/autosuggest-input/styles.css.js'; -let mockFieldWidth: number | null = null; - interface Geometry { - field: number | null; token: number; trigger: number; // Combined width of the tokens and the input, and how much of it the tokens take. - available?: number; + available: number; tokensShare?: number; gap?: number; inputMinWidth?: string; } -function setGeometry({ - field, - token, - trigger, - available = field ?? 0, - tokensShare = 0.5, - gap = 0, - inputMinWidth = '0px', -}: Geometry) { - mockFieldWidth = field; +function setGeometry({ token, trigger, available, tokensShare = 0.5, gap = 0, inputMinWidth = '0px' }: Geometry) { const tokensWidth = Math.round(available * tokensShare); jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { if (this.classList.contains(styles['measurement-token'])) { @@ -50,18 +38,16 @@ function setGeometry({ afterEach(() => { jest.restoreAllMocks(); - mockFieldWidth = null; }); -function Harness({ - tokenCount, - enabled = true, - layoutKey = '', -}: { +interface HarnessProps { tokenCount: number; + fieldWidth: number | null; enabled?: boolean; layoutKey?: string; -}) { +} + +function Harness({ tokenCount, fieldWidth, enabled = true, layoutKey = '' }: HarnessProps) { const measureRef = React.useRef(null); const measureTriggerRef = React.useRef(null); const tokensRef = React.useRef(null); @@ -74,7 +60,7 @@ function Harness({ measureTriggerRef, tokensRef, inputRef, - fieldWidth: mockFieldWidth, + fieldWidth, layoutKey, }); return ( @@ -94,75 +80,75 @@ function Harness({ ); } -const visibleCountFor = (props: { tokenCount: number; enabled?: boolean }) => +const visibleCountFor = (props: HarnessProps) => Number(render().container.querySelector('output')!.textContent); describe('useTokenOverflow', () => { test('shows all tokens before the field is measured', () => { - setGeometry({ field: null, token: 100, trigger: 30 }); - expect(visibleCountFor({ tokenCount: 5 })).toBe(5); + setGeometry({ available: 0, token: 100, trigger: 30 }); + expect(visibleCountFor({ tokenCount: 5, fieldWidth: null })).toBe(5); }); test('shows all tokens when disabled', () => { - setGeometry({ field: 50, token: 100, trigger: 30 }); - expect(visibleCountFor({ tokenCount: 5, enabled: false })).toBe(5); + setGeometry({ available: 50, token: 100, trigger: 30 }); + expect(visibleCountFor({ tokenCount: 5, fieldWidth: 50, enabled: false })).toBe(5); }); test('shows all tokens when they fit', () => { - setGeometry({ field: 300, token: 100, trigger: 30 }); - expect(visibleCountFor({ tokenCount: 3 })).toBe(3); + setGeometry({ available: 300, token: 100, trigger: 30 }); + expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3); }); test('keeps the trailing tokens that fit next to the overflow trigger', () => { - setGeometry({ field: 240, token: 100, trigger: 30 }); - expect(visibleCountFor({ tokenCount: 5 })).toBe(2); + setGeometry({ available: 240, token: 100, trigger: 30 }); + expect(visibleCountFor({ tokenCount: 5, fieldWidth: 240 })).toBe(2); }); test('never shows a partially visible token', () => { - setGeometry({ field: 229, token: 100, trigger: 30 }); - expect(visibleCountFor({ tokenCount: 5 })).toBe(1); + setGeometry({ available: 229, token: 100, trigger: 30 }); + expect(visibleCountFor({ tokenCount: 5, fieldWidth: 229 })).toBe(1); }); test('hides all tokens when none fit next to the trigger', () => { - setGeometry({ field: 100, token: 100, trigger: 30 }); - expect(visibleCountFor({ tokenCount: 2 })).toBe(0); + setGeometry({ available: 100, token: 100, trigger: 30 }); + expect(visibleCountFor({ tokenCount: 2, fieldWidth: 100 })).toBe(0); }); test('accounts for the gaps between tokens', () => { - setGeometry({ field: 300, token: 100, trigger: 30, gap: 4 }); - expect(visibleCountFor({ tokenCount: 3 })).toBe(2); + setGeometry({ available: 300, token: 100, trigger: 30, gap: 4 }); + expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(2); }); test('does not depend on how the field is currently split between tokens and input', () => { - setGeometry({ field: 300, token: 100, trigger: 30, tokensShare: 0 }); - expect(visibleCountFor({ tokenCount: 3 })).toBe(3); + setGeometry({ available: 300, token: 100, trigger: 30, tokensShare: 0 }); + expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3); jest.restoreAllMocks(); - setGeometry({ field: 300, token: 100, trigger: 30, tokensShare: 1 }); - expect(visibleCountFor({ tokenCount: 3 })).toBe(3); + setGeometry({ available: 300, token: 100, trigger: 30, tokensShare: 1 }); + expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3); }); test('re-measures when the field layout changes', () => { - setGeometry({ field: 300, token: 100, trigger: 30 }); - const { container, rerender } = render(); + setGeometry({ available: 300, token: 100, trigger: 30 }); + const { container, rerender } = render(); expect(container.querySelector('output')).toHaveTextContent('3'); jest.restoreAllMocks(); - setGeometry({ field: 300, token: 100, trigger: 30, available: 280 }); - rerender(); + setGeometry({ available: 280, token: 100, trigger: 30 }); + rerender(); expect(container.querySelector('output')).toHaveTextContent('2'); }); test('shows all tokens while the field is hidden and re-measures when it is shown again', () => { - setGeometry({ field: 0, token: 0, trigger: 0 }); - const { container, rerender } = render(); + setGeometry({ available: 0, token: 0, trigger: 0 }); + const { container, rerender } = render(); expect(container.querySelector('output')).toHaveTextContent('5'); jest.restoreAllMocks(); - setGeometry({ field: 240, token: 100, trigger: 30 }); - rerender(); + setGeometry({ available: 240, token: 100, trigger: 30 }); + rerender(); expect(container.querySelector('output')).toHaveTextContent('2'); }); test('reserves the input minimum width defined in CSS', () => { - setGeometry({ field: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); - expect(visibleCountFor({ tokenCount: 3 })).toBe(2); + setGeometry({ available: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); + expect(visibleCountFor({ tokenCount: 3, fieldWidth: 399 })).toBe(2); }); }); From 6735d4dde395f9b2c125a1467e460e6a0cb4da05 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 2 Nov 2026 15:41:54 +0100 Subject: [PATCH 8/9] Refactor --- .../components/autosuggest-input/index.tsx | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index e4bc04d32e..b9230e6ffe 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -408,24 +408,6 @@ const AutosuggestInput = React.forwardRef( } }; - const sharedInputProps = { - value, - onChange: (event: { detail: BaseChangeDetail }) => handleChange(event.detail.value), - __onDelayedInput: (event: { detail: BaseChangeDetail }) => handleDelayedInput(event.detail.value), - onKeyDown: handleKeyDown, - onKeyUp, - disabled, - disableBrowserAutocorrect, - readOnly, - ariaRequired, - clearAriaLabel, - ref: inputRef, - __internalRootRef: mergedContainerRef, - autoComplete: false, - nativeInputAttributes: processAttributes(nativeAttributes, 'Autosuggest', nativeInputAttributes), - __skipNativeAttributesWarnings: Object.keys(nativeAttributes), - }; - const inlineTokens = tokenList.length > 0 && ( handleChange(event.detail.value)} + __onDelayedInput={event => handleDelayedInput(event.detail.value)} + onKeyDown={handleKeyDown} + onKeyUp={onKeyUp} + disabled={disabled} + disableBrowserAutocorrect={disableBrowserAutocorrect} + readOnly={readOnly} + ariaRequired={ariaRequired} + clearAriaLabel={clearAriaLabel} + ref={inputRef} + __internalRootRef={mergedContainerRef} + autoComplete={false} + nativeInputAttributes={processAttributes(nativeAttributes, 'Autosuggest', nativeInputAttributes)} + __skipNativeAttributesWarnings={Object.keys(nativeAttributes)} style={style} {...formFieldContext} __inlineContent={inlineTokens} From d44fe7ae8a020da8b655c6a77593c26c61a47686 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 2 Nov 2026 15:56:03 +0100 Subject: [PATCH 9/9] make the measurement copy inert even when there are no tokens --- .../__tests__/use-token-overflow.test.tsx | 10 ++++++++-- .../components/autosuggest-input/use-token-overflow.ts | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx index f6833401cc..a621f8933d 100644 --- a/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx +++ b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx @@ -65,14 +65,14 @@ function Harness({ tokenCount, fieldWidth, enabled = true, layoutKey = '' }: Har }); return (
-
+
{Array.from({ length: tokenCount }, (_, i) => ( ))}
-
+ {tokenCount > 0 &&
}
{visibleCount} @@ -147,6 +147,12 @@ describe('useTokenOverflow', () => { expect(container.querySelector('output')).toHaveTextContent('2'); }); + test('makes the measurement copy inert even when there are no tokens', () => { + setGeometry({ available: 300, token: 100, trigger: 30 }); + const { getByTestId } = render(); + expect(getByTestId('measure').inert).toBe(true); + }); + test('reserves the input minimum width defined in CSS', () => { setGeometry({ available: 399, token: 100, trigger: 30, inputMinWidth: '25%' }); expect(visibleCountFor({ tokenCount: 3, fieldWidth: 399 })).toBe(2); diff --git a/src/internal/components/autosuggest-input/use-token-overflow.ts b/src/internal/components/autosuggest-input/use-token-overflow.ts index 9e165cca6a..b5483bf06d 100644 --- a/src/internal/components/autosuggest-input/use-token-overflow.ts +++ b/src/internal/components/autosuggest-input/use-token-overflow.ts @@ -37,11 +37,13 @@ export function useTokenOverflow({ const tokens = tokensRef.current; const input = inputRef.current; const measure = measureRef.current; + if (measure) { + measure.inert = true; + } if (!enabled || !tokens || !input || !measure || !fieldWidth) { setVisibleCount(tokenCount); return; } - measure.inert = true; // The tokens and the input share the available space, whatever the tokens currently take. const availableWidth = tokens.offsetWidth + input.offsetWidth;