Skip to content

Commit 6125eb6

Browse files
authored
fix: Adjust spacings in token mode autosuggest (#5207)
1 parent c8ca653 commit 6125eb6

13 files changed

Lines changed: 206 additions & 342 deletions

File tree

‎src/autosuggest/__tests__/autosuggest-token-mode.test.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,8 @@ import createWrapper from '../../../lib/components/test-utils/dom';
1717

1818
let mockVisibleCount: number | null = null;
1919
jest.mock('../../../lib/components/internal/components/autosuggest-input/use-token-overflow', () => ({
20-
useTokenOverflow: ({ tokenCount }: { tokenCount: number }) => ({
21-
containerRef: () => {},
22-
visibleCount: mockVisibleCount === null ? tokenCount : Math.min(mockVisibleCount, tokenCount),
23-
}),
20+
useTokenOverflow: ({ tokenCount }: { tokenCount: number }) =>
21+
mockVisibleCount === null ? tokenCount : Math.min(mockVisibleCount, tokenCount),
2422
}));
2523

2624
jest.mock('@cloudscape-design/component-toolkit/internal', () => ({

‎src/autosuggest/__tests__/autosuggest.test.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -674,7 +674,6 @@ describe('native attributes', () => {
674674

675675
it('warns about overriding autosuggest-specific attributes', () => {
676676
renderAutosuggest(<Autosuggest {...defaultProps} nativeInputAttributes={{ 'aria-autocomplete': 'both' }} />);
677-
expect(warnOnce).toHaveBeenCalledTimes(1);
678677
expect(warnOnce).toHaveBeenCalledWith(
679678
'Autosuggest',
680679
'Overriding native attribute [aria-autocomplete] which has a Cloudscape-provided value'
@@ -685,7 +684,6 @@ describe('native attributes', () => {
685684
renderAutosuggest(
686685
<Autosuggest {...defaultProps} ariaRequired={true} nativeInputAttributes={{ 'aria-required': 'false' }} />
687686
);
688-
expect(warnOnce).toHaveBeenCalledTimes(1);
689687
expect(warnOnce).toHaveBeenCalledWith(
690688
'Input',
691689
'Overriding native attribute [aria-required] which has a Cloudscape-provided value'

‎src/input/internal.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export interface InternalInputProps
5151
__onEndIconClick?: () => void;
5252

5353
__noBorderRadius?: boolean;
54-
__noBorder?: boolean;
54+
55+
// Rendered inside the field, between the start icon and the native input.
56+
__inlineContent?: React.ReactNode;
5557

5658
__onDelayedInput?: NonCancelableEventHandler<BaseChangeDetail>;
5759
__onBlurWithDetail?: NonCancelableEventHandler<{ relatedTarget: Node | null }>;
@@ -89,7 +91,7 @@ function InternalInput(
8991
disableBrowserAutocorrect,
9092
spellcheck,
9193
__noBorderRadius,
92-
__noBorder,
94+
__inlineContent,
9395

9496
__startIcon,
9597
__onStartIconClick,
@@ -340,7 +342,7 @@ function InternalInput(
340342
disabled && styles['input-field-disabled'],
341343
readOnly && !disabled && styles['input-field-readonly'],
342344
__noBorderRadius && styles['input-field-no-border-radius'],
343-
__noBorder && styles['input-field-no-border'],
345+
!!__inlineContent && styles['input-field-has-inline-content'],
344346
groupedControlClasses
345347
)}
346348
aria-disabled={disabled || undefined}
@@ -356,6 +358,11 @@ function InternalInput(
356358
<span className={styles['input-adornment-divider']} />
357359
</>
358360
)}
361+
{__inlineContent && (
362+
<div className={styles['input-inline-content']}>
363+
<ResetGroupedControlContext>{__inlineContent}</ResetGroupedControlContext>
364+
</div>
365+
)}
359366
{mainInput}
360367
{hasSuffix && (
361368
<>

‎src/input/styles.scss‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ $input-tokens: style-api.resolve(
177177
inline-size: 100%;
178178
box-sizing: border-box;
179179
cursor: text;
180-
overflow: clip;
181180
color: var(#{custom-props.$styleColorDefault}, awsui.$color-text-form-secondary);
182181
border-start-start-radius: styles.$control-border-radius;
183182
border-start-end-radius: styles.$control-border-radius;
@@ -194,6 +193,11 @@ $input-tokens: style-api.resolve(
194193
@include styles.input-hover-styles;
195194
}
196195

196+
// Inline content can open popovers (e.g. the token overflow dropdown) that must not be clipped.
197+
&:not(.input-field-has-inline-content) {
198+
overflow: clip;
199+
}
200+
197201
&.input-field-readonly {
198202
color: var(
199203
#{custom-props.$styleColorReadonly},
@@ -260,19 +264,6 @@ $input-tokens: style-api.resolve(
260264
border-end-end-radius: awsui.$border-radius-dropdown;
261265
}
262266

263-
// Nested so the grouped corner rules win over the field's own radius by specificity, not by source order.
264-
&.input-field-no-border {
265-
min-block-size: unset;
266-
&,
267-
&:hover,
268-
&:focus-within {
269-
border-block-width: 0;
270-
border-inline-width: 0;
271-
box-shadow: none;
272-
background-color: transparent;
273-
}
274-
}
275-
276267
&.grouped {
277268
// Unlike other controls, input's padding is rendered by the inner input element and not by its wrapper,
278269
// so we should not add extra space to the wrapper for the validation border.
@@ -316,15 +307,41 @@ $input-tokens: style-api.resolve(
316307
cursor: default;
317308
}
318309

310+
// Starts the inline content one small gap after the start icon, which is offset from the outer border edge.
311+
$inline-content-icon-padding: calc(
312+
#{styles.$control-icon-horizontal-offset} + #{awsui.$size-icon-normal} + #{awsui.$space-xxs} -
313+
#{awsui.$border-width-field}
314+
);
315+
316+
.input-inline-content {
317+
display: flex;
318+
min-inline-size: 0;
319+
padding-inline-start: $inline-content-icon-padding;
320+
// Inline content is not editable text, so it does not show the field's text cursor.
321+
cursor: default;
322+
323+
// The inline content already clears the start icon; the input keeps room to type.
324+
+ .input {
325+
padding-inline-start: styles.$control-padding-horizontal;
326+
min-inline-size: 20%;
327+
}
328+
}
329+
319330
// The wider invalid/warning start border is absorbed by the first cell, so the content does not shift.
320-
// The first cell is the prefix when present, otherwise the native input.
331+
// The first cell is the prefix when present, then the inline content, otherwise the native input.
321332
.input-field-invalid,
322333
.input-field-warning {
323334
> .input-prefix,
324335
> .input:first-child {
325336
padding-inline-start: styles.$invalid-control-left-padding;
326337
}
327338

339+
> .input-inline-content:first-child {
340+
padding-inline-start: calc(
341+
#{$inline-content-icon-padding} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field})
342+
);
343+
}
344+
328345
> .input-has-icon-start:first-child {
329346
padding-inline-start: calc(
330347
#{styles.$control-icon-horizontal-padding} -

‎src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx‎

Lines changed: 71 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -7,158 +7,154 @@ import { useTokenOverflow } from '../../../../../lib/components/internal/compone
77

88
import styles from '../../../../../lib/components/internal/components/autosuggest-input/styles.css.js';
99

10-
let mockContainerWidth: number | null = null;
11-
jest.mock('@cloudscape-design/component-toolkit', () => ({
12-
...jest.requireActual('@cloudscape-design/component-toolkit'),
13-
useContainerQuery: () => [mockContainerWidth, () => {}],
14-
}));
15-
1610
interface Geometry {
17-
container: number | null;
1811
token: number;
1912
trigger: number;
20-
icon: number;
21-
padding?: number;
22-
border?: number;
13+
// Combined width of the tokens and the input, and how much of it the tokens take.
14+
available: number;
15+
tokensShare?: number;
2316
gap?: number;
2417
inputMinWidth?: string;
2518
}
2619

27-
function setGeometry({
28-
container,
29-
token,
30-
trigger,
31-
icon,
32-
padding = 0,
33-
border = 0,
34-
gap = 0,
35-
inputMinWidth = '0px',
36-
}: Geometry) {
37-
mockContainerWidth = container;
20+
function setGeometry({ token, trigger, available, tokensShare = 0.5, gap = 0, inputMinWidth = '0px' }: Geometry) {
21+
const tokensWidth = Math.round(available * tokensShare);
3822
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
3923
if (this.classList.contains(styles['measurement-token'])) {
4024
return token;
4125
}
42-
return { trigger, icon }[this.dataset.part as 'trigger' | 'icon'] ?? 0;
26+
const widths = { trigger, tokens: tokensWidth, input: available - tokensWidth };
27+
return widths[this.dataset.part as keyof typeof widths] ?? 0;
4328
});
4429
const getComputedStyle = window.getComputedStyle;
4530
jest.spyOn(window, 'getComputedStyle').mockImplementation(element => {
4631
const style = getComputedStyle(element);
47-
const overrides: Record<string, string> = {
48-
paddingInlineStart: `${padding}px`,
49-
paddingInlineEnd: `${padding}px`,
50-
borderInlineStartWidth: `${border}px`,
51-
borderInlineEndWidth: `${border}px`,
52-
columnGap: `${gap}px`,
53-
minInlineSize: (element as HTMLElement).dataset.part === 'input' ? inputMinWidth : style.minInlineSize,
54-
};
32+
const part = (element as HTMLElement).dataset.part;
33+
const overrides: Record<string, string> =
34+
part === 'tokens' ? { columnGap: `${gap}px` } : part === 'input' ? { minInlineSize: inputMinWidth } : {};
5535
return new Proxy(style, { get: (target, prop: string) => overrides[prop] ?? (target as any)[prop] });
5636
});
5737
}
5838

5939
afterEach(() => {
6040
jest.restoreAllMocks();
61-
mockContainerWidth = null;
6241
});
6342

64-
function Harness({
65-
tokenCount,
66-
enabled = true,
67-
layoutKey = '',
68-
}: {
43+
interface HarnessProps {
6944
tokenCount: number;
45+
fieldWidth: number | null;
7046
enabled?: boolean;
7147
layoutKey?: string;
72-
}) {
48+
}
49+
50+
function Harness({ tokenCount, fieldWidth, enabled = true, layoutKey = '' }: HarnessProps) {
7351
const measureRef = React.useRef<HTMLDivElement>(null);
7452
const measureTriggerRef = React.useRef<HTMLSpanElement>(null);
75-
const fieldRef = React.useRef<HTMLDivElement>(null);
76-
const leadingIconRef = React.useRef<HTMLSpanElement>(null);
77-
const inputWrapperRef = React.useRef<HTMLDivElement>(null);
78-
const { containerRef, visibleCount } = useTokenOverflow({
53+
const tokensRef = React.useRef<HTMLDivElement>(null);
54+
const inputRef = React.useRef<HTMLInputElement>(null);
55+
const visibleCount = useTokenOverflow({
7956
enabled,
8057
tokenCount,
8158
tokenContentKey: String(tokenCount),
8259
measureRef,
8360
measureTriggerRef,
84-
fieldRef,
85-
leadingIconRef,
86-
inputWrapperRef,
61+
tokensRef,
62+
inputRef,
63+
fieldWidth,
8764
layoutKey,
8865
});
8966
return (
90-
<div ref={containerRef}>
91-
<div ref={measureRef}>
67+
<div>
68+
<div ref={measureRef} data-testid="measure">
9269
{Array.from({ length: tokenCount }, (_, i) => (
9370
<span key={i} className={styles['measurement-token']} />
9471
))}
9572
<span ref={measureTriggerRef} data-part="trigger" />
9673
</div>
97-
<div ref={fieldRef}>
98-
<span ref={leadingIconRef} data-part="icon" />
99-
<div ref={inputWrapperRef} data-part="input" />
74+
<div>
75+
{tokenCount > 0 && <div ref={tokensRef} data-part="tokens" />}
76+
<input ref={inputRef} data-part="input" />
10077
</div>
10178
<output>{visibleCount}</output>
10279
</div>
10380
);
10481
}
10582

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

10986
describe('useTokenOverflow', () => {
110-
test('shows all tokens before the container is measured', () => {
111-
setGeometry({ container: null, token: 100, trigger: 30, icon: 20 });
112-
expect(visibleCountFor({ tokenCount: 5 })).toBe(5);
87+
test('shows all tokens before the field is measured', () => {
88+
setGeometry({ available: 0, token: 100, trigger: 30 });
89+
expect(visibleCountFor({ tokenCount: 5, fieldWidth: null })).toBe(5);
11390
});
11491

11592
test('shows all tokens when disabled', () => {
116-
setGeometry({ container: 50, token: 100, trigger: 30, icon: 20 });
117-
expect(visibleCountFor({ tokenCount: 5, enabled: false })).toBe(5);
93+
setGeometry({ available: 50, token: 100, trigger: 30 });
94+
expect(visibleCountFor({ tokenCount: 5, fieldWidth: 50, enabled: false })).toBe(5);
11895
});
11996

12097
test('shows all tokens when they fit', () => {
121-
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20 });
122-
expect(visibleCountFor({ tokenCount: 3 })).toBe(3);
98+
setGeometry({ available: 300, token: 100, trigger: 30 });
99+
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3);
123100
});
124101

125102
test('keeps the trailing tokens that fit next to the overflow trigger', () => {
126-
setGeometry({ container: 260, token: 100, trigger: 30, icon: 20 });
127-
expect(visibleCountFor({ tokenCount: 5 })).toBe(2);
103+
setGeometry({ available: 240, token: 100, trigger: 30 });
104+
expect(visibleCountFor({ tokenCount: 5, fieldWidth: 240 })).toBe(2);
128105
});
129106

130107
test('never shows a partially visible token', () => {
131-
setGeometry({ container: 249, token: 100, trigger: 30, icon: 20 });
132-
expect(visibleCountFor({ tokenCount: 5 })).toBe(1);
108+
setGeometry({ available: 229, token: 100, trigger: 30 });
109+
expect(visibleCountFor({ tokenCount: 5, fieldWidth: 229 })).toBe(1);
133110
});
134111

135112
test('hides all tokens when none fit next to the trigger', () => {
136-
setGeometry({ container: 120, token: 100, trigger: 30, icon: 20 });
137-
expect(visibleCountFor({ tokenCount: 2 })).toBe(0);
113+
setGeometry({ available: 100, token: 100, trigger: 30 });
114+
expect(visibleCountFor({ tokenCount: 2, fieldWidth: 100 })).toBe(0);
138115
});
139116

140-
test('accounts for padding and gaps', () => {
141-
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, padding: 10, gap: 4 });
142-
expect(visibleCountFor({ tokenCount: 3 })).toBe(2);
117+
test('accounts for the gaps between tokens', () => {
118+
setGeometry({ available: 300, token: 100, trigger: 30, gap: 4 });
119+
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(2);
143120
});
144121

145-
test('accounts for the field borders', () => {
146-
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, border: 1 });
147-
expect(visibleCountFor({ tokenCount: 3 })).toBe(2);
122+
test('does not depend on how the field is currently split between tokens and input', () => {
123+
setGeometry({ available: 300, token: 100, trigger: 30, tokensShare: 0 });
124+
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3);
125+
jest.restoreAllMocks();
126+
setGeometry({ available: 300, token: 100, trigger: 30, tokensShare: 1 });
127+
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 300 })).toBe(3);
148128
});
149129

150130
test('re-measures when the field layout changes', () => {
151-
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20 });
152-
const { container, rerender } = render(<Harness tokenCount={3} layoutKey="valid" />);
131+
setGeometry({ available: 300, token: 100, trigger: 30 });
132+
const { container, rerender } = render(<Harness tokenCount={3} fieldWidth={300} layoutKey="valid" />);
153133
expect(container.querySelector('output')).toHaveTextContent('3');
154134
jest.restoreAllMocks();
155-
setGeometry({ container: 320, token: 100, trigger: 30, icon: 20, padding: 10 });
156-
rerender(<Harness tokenCount={3} layoutKey="invalid" />);
135+
setGeometry({ available: 280, token: 100, trigger: 30 });
136+
rerender(<Harness tokenCount={3} fieldWidth={300} layoutKey="invalid" />);
137+
expect(container.querySelector('output')).toHaveTextContent('2');
138+
});
139+
140+
test('shows all tokens while the field is hidden and re-measures when it is shown again', () => {
141+
setGeometry({ available: 0, token: 0, trigger: 0 });
142+
const { container, rerender } = render(<Harness tokenCount={5} fieldWidth={0} />);
143+
expect(container.querySelector('output')).toHaveTextContent('5');
144+
jest.restoreAllMocks();
145+
setGeometry({ available: 240, token: 100, trigger: 30 });
146+
rerender(<Harness tokenCount={5} fieldWidth={240} />);
157147
expect(container.querySelector('output')).toHaveTextContent('2');
158148
});
159149

150+
test('makes the measurement copy inert even when there are no tokens', () => {
151+
setGeometry({ available: 300, token: 100, trigger: 30 });
152+
const { getByTestId } = render(<Harness tokenCount={0} fieldWidth={300} />);
153+
expect(getByTestId('measure').inert).toBe(true);
154+
});
155+
160156
test('reserves the input minimum width defined in CSS', () => {
161-
setGeometry({ container: 399, token: 100, trigger: 30, icon: 0, inputMinWidth: '25%' });
162-
expect(visibleCountFor({ tokenCount: 3 })).toBe(2);
157+
setGeometry({ available: 399, token: 100, trigger: 30, inputMinWidth: '25%' });
158+
expect(visibleCountFor({ tokenCount: 3, fieldWidth: 399 })).toBe(2);
163159
});
164160
});

0 commit comments

Comments
 (0)