+
{Array.from({ length: tokenCount }, (_, i) => (
))}
-
);
}
-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 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(
);
+ setGeometry({ available: 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 });
- 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({ available: 0, token: 0, trigger: 0 });
+ const { container, rerender } = render(
);
+ expect(container.querySelector('output')).toHaveTextContent('5');
+ jest.restoreAllMocks();
+ setGeometry({ available: 240, token: 100, trigger: 30 });
+ rerender(
);
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({ 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);
});
});
diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx
index c279710254..b9230e6ffe 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,32 +141,30 @@ 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 inlineTokensRootRef = 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),
- entry => entry.borderBoxWidth > 0 && setTriggerWidth(entry.borderBoxWidth)
+ () => inputContainerRef.current,
+ entry => setTriggerWidth(entry.borderBoxWidth)
);
const [open, setOpen] = useState(false);
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'),
measureRef,
measureTriggerRef,
- fieldRef,
- leadingIconRef,
- inputWrapperRef,
+ tokensRef: inlineTokensRootRef,
+ inputRef,
+ fieldWidth: triggerWidth,
layoutKey: [
formFieldContext.invalid,
formFieldContext.warning,
@@ -194,14 +189,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 +367,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 +380,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();
}
@@ -415,87 +408,31 @@ 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: inputContainerRef,
- 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 +443,27 @@ const AutosuggestInput = React.forwardRef(
onFocus={handleFocus}
onBlur={handleBlur}
trigger={
- tokenField ||
+ 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}
+ />
}
onMouseDown={handleDropdownMouseDown}
open={open && (!!dropdownContent || !!dropdownFooter)}
@@ -555,9 +512,7 @@ const AutosuggestInput = React.forwardRef(
{`+${tokenList.length}`}
+ {dropdown}
>
) : (
dropdown
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