Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/autosuggest/__tests__/autosuggest-token-mode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
2 changes: 0 additions & 2 deletions src/autosuggest/__tests__/autosuggest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,6 @@ describe('native attributes', () => {

it('warns about overriding autosuggest-specific attributes', () => {
renderAutosuggest(<Autosuggest {...defaultProps} nativeInputAttributes={{ 'aria-autocomplete': 'both' }} />);
expect(warnOnce).toHaveBeenCalledTimes(1);

@jperals jperals Nov 2, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning will now be issued twice in the tests because widths are always 0 in jsdom environments (see this comment for more context), so the behavior here differs from real user experience and this assertion is not very valuable. warnOnce would deduplicate the warning anyway, so real users get only one warning in any case.

expect(warnOnce).toHaveBeenCalledWith(
'Autosuggest',
'Overriding native attribute [aria-autocomplete] which has a Cloudscape-provided value'
Expand All @@ -685,7 +684,6 @@ describe('native attributes', () => {
renderAutosuggest(
<Autosuggest {...defaultProps} ariaRequired={true} nativeInputAttributes={{ 'aria-required': 'false' }} />
);
expect(warnOnce).toHaveBeenCalledTimes(1);
expect(warnOnce).toHaveBeenCalledWith(
'Input',
'Overriding native attribute [aria-required] which has a Cloudscape-provided value'
Expand Down
13 changes: 10 additions & 3 deletions src/input/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export interface InternalInputProps
__onEndIconClick?: () => void;

__noBorderRadius?: boolean;
__noBorder?: boolean;

// Rendered inside the field, between the start icon and the native input.
__inlineContent?: React.ReactNode;

__onDelayedInput?: NonCancelableEventHandler<BaseChangeDetail>;
__onBlurWithDetail?: NonCancelableEventHandler<{ relatedTarget: Node | null }>;
Expand Down Expand Up @@ -89,7 +91,7 @@ function InternalInput(
disableBrowserAutocorrect,
spellcheck,
__noBorderRadius,
__noBorder,
__inlineContent,

__startIcon,
__onStartIconClick,
Expand Down Expand Up @@ -340,7 +342,7 @@ 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'],
!!__inlineContent && styles['input-field-has-inline-content'],
groupedControlClasses
)}
aria-disabled={disabled || undefined}
Expand All @@ -356,6 +358,11 @@ function InternalInput(
<span className={styles['input-adornment-divider']} />
</>
)}
{__inlineContent && (
<div className={styles['input-inline-content']}>
<ResetGroupedControlContext>{__inlineContent}</ResetGroupedControlContext>
</div>
)}
{mainInput}
{hasSuffix && (
<>
Expand Down
47 changes: 32 additions & 15 deletions src/input/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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},
Expand Down Expand Up @@ -260,19 +264,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.
Expand Down Expand Up @@ -316,15 +307,41 @@ $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;
min-inline-size: 0;
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 {
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:first-child {
padding-inline-start: styles.$invalid-control-left-padding;
}

> .input-inline-content:first-child {
padding-inline-start: calc(
#{$inline-content-icon-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field})
);
}

> .input-has-icon-start:first-child {
padding-inline-start: calc(
#{styles.$control-icon-horizontal-padding} -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,158 +7,154 @@ 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, () => {}],
}));

interface Geometry {
container: number | null;
token: number;
trigger: number;
icon: number;
padding?: number;
border?: number;
// Combined width of the tokens and the input, and how much of it the tokens take.
available: number;
tokensShare?: number;
gap?: number;
inputMinWidth?: string;
}

function setGeometry({
container,
token,
trigger,
icon,
padding = 0,
border = 0,
gap = 0,
inputMinWidth = '0px',
}: Geometry) {
mockContainerWidth = container;
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'])) {
return token;
}
return { trigger, icon }[this.dataset.part as 'trigger' | 'icon'] ?? 0;
const widths = { trigger, tokens: tokensWidth, input: available - tokensWidth };
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<string, string> = {
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<string, string> =
part === 'tokens' ? { columnGap: `${gap}px` } : part === 'input' ? { minInlineSize: inputMinWidth } : {};
return new Proxy(style, { get: (target, prop: string) => overrides[prop] ?? (target as any)[prop] });
});
}

afterEach(() => {
jest.restoreAllMocks();
mockContainerWidth = 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<HTMLDivElement>(null);
const measureTriggerRef = React.useRef<HTMLSpanElement>(null);
const fieldRef = React.useRef<HTMLDivElement>(null);
const leadingIconRef = React.useRef<HTMLSpanElement>(null);
const inputWrapperRef = React.useRef<HTMLDivElement>(null);
const { containerRef, visibleCount } = useTokenOverflow({
const tokensRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const visibleCount = useTokenOverflow({
enabled,
tokenCount,
tokenContentKey: String(tokenCount),
measureRef,
measureTriggerRef,
fieldRef,
leadingIconRef,
inputWrapperRef,
tokensRef,
inputRef,
fieldWidth,
layoutKey,
});
return (
<div ref={containerRef}>
<div ref={measureRef}>
<div>
<div ref={measureRef} data-testid="measure">
{Array.from({ length: tokenCount }, (_, i) => (
<span key={i} className={styles['measurement-token']} />
))}
<span ref={measureTriggerRef} data-part="trigger" />
</div>
<div ref={fieldRef}>
<span ref={leadingIconRef} data-part="icon" />
<div ref={inputWrapperRef} data-part="input" />
<div>
{tokenCount > 0 && <div ref={tokensRef} data-part="tokens" />}
<input ref={inputRef} data-part="input" />
</div>
<output>{visibleCount}</output>
</div>
);
}

const visibleCountFor = (props: { tokenCount: number; enabled?: boolean }) =>
const visibleCountFor = (props: HarnessProps) =>
Number(render(<Harness {...props} />).container.querySelector('output')!.textContent);

describe('useTokenOverflow', () => {
test('shows all tokens before the container is measured', () => {
setGeometry({ container: null, token: 100, trigger: 30, icon: 20 });
expect(visibleCountFor({ tokenCount: 5 })).toBe(5);
test('shows all tokens before the field is measured', () => {
setGeometry({ available: 0, token: 100, trigger: 30 });
expect(visibleCountFor({ tokenCount: 5, fieldWidth: null })).toBe(5);
});

test('shows all tokens when disabled', () => {
setGeometry({ container: 50, token: 100, trigger: 30, icon: 20 });
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({ container: 320, token: 100, trigger: 30, icon: 20 });
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({ container: 260, token: 100, trigger: 30, icon: 20 });
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({ container: 249, token: 100, trigger: 30, icon: 20 });
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({ container: 120, token: 100, trigger: 30, icon: 20 });
expect(visibleCountFor({ tokenCount: 2 })).toBe(0);
setGeometry({ available: 100, token: 100, trigger: 30 });
expect(visibleCountFor({ tokenCount: 2, fieldWidth: 100 })).toBe(0);
});

test('accounts for padding and gaps', () => {
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, padding: 10, gap: 4 });
expect(visibleCountFor({ tokenCount: 3 })).toBe(2);
test('accounts for the gaps between tokens', () => {
setGeometry({ available: 300, token: 100, trigger: 30, gap: 4 });
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).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({ available: 300, token: 100, trigger: 30, tokensShare: 0 });
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3);
jest.restoreAllMocks();
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({ container: 320, token: 100, trigger: 30, icon: 20 });
const { container, rerender } = render(<Harness tokenCount={3} layoutKey="valid" />);
setGeometry({ available: 300, token: 100, trigger: 30 });
const { container, rerender } = render(<Harness tokenCount={3} fieldWidth={300} layoutKey="valid" />);
expect(container.querySelector('output')).toHaveTextContent('3');
jest.restoreAllMocks();
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, padding: 10 });
rerender(<Harness tokenCount={3} layoutKey="invalid" />);
setGeometry({ available: 280, token: 100, trigger: 30 });
rerender(<Harness tokenCount={3} fieldWidth={300} layoutKey="invalid" />);
expect(container.querySelector('output')).toHaveTextContent('2');
});

test('shows all tokens while the field is hidden and re-measures when it is shown again', () => {
setGeometry({ available: 0, token: 0, trigger: 0 });
const { container, rerender } = render(<Harness tokenCount={5} fieldWidth={0} />);
expect(container.querySelector('output')).toHaveTextContent('5');
jest.restoreAllMocks();
setGeometry({ available: 240, token: 100, trigger: 30 });
rerender(<Harness tokenCount={5} fieldWidth={240} />);
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(<Harness tokenCount={0} fieldWidth={300} />);
expect(getByTestId('measure').inert).toBe(true);
});

test('reserves the input minimum width defined in CSS', () => {
setGeometry({ container: 399, token: 100, trigger: 30, icon: 0, 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);
});
});
Loading
Loading