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/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/input/internal.tsx b/src/input/internal.tsx index d8662a9b66..fae57a37e2 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -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; __onBlurWithDetail?: NonCancelableEventHandler<{ relatedTarget: Node | null }>; @@ -89,7 +91,7 @@ function InternalInput( disableBrowserAutocorrect, spellcheck, __noBorderRadius, - __noBorder, + __inlineContent, __startIcon, __onStartIconClick, @@ -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} @@ -356,6 +358,11 @@ function InternalInput( )} + {__inlineContent && ( +
+ {__inlineContent} +
+ )} {mainInput} {hasSuffix && ( <> diff --git a/src/input/styles.scss b/src/input/styles.scss index 479f5f77a6..5437083c85 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}, @@ -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. @@ -316,8 +307,28 @@ $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, @@ -325,6 +336,12 @@ $input-tokens: style-api.resolve( 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} - 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..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 @@ -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 = { - 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 === '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(null); const measureTriggerRef = React.useRef(null); - const fieldRef = React.useRef(null); - const leadingIconRef = React.useRef(null); - const inputWrapperRef = React.useRef(null); - const { containerRef, visibleCount } = useTokenOverflow({ + const tokensRef = React.useRef(null); + const inputRef = React.useRef(null); + const visibleCount = useTokenOverflow({ enabled, tokenCount, tokenContentKey: String(tokenCount), measureRef, measureTriggerRef, - fieldRef, - leadingIconRef, - inputWrapperRef, + tokensRef, + inputRef, + fieldWidth, layoutKey, }); return ( -
-
+
+
{Array.from({ length: tokenCount }, (_, i) => ( ))}
-
- -
+
+ {tokenCount > 0 &&
} +
{visibleCount}
); } -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} ) : ( 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; } 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; - fieldRef: React.RefObject; - leadingIconRef: React.RefObject; - inputWrapperRef: 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. */ @@ -33,58 +26,42 @@ export function useTokenOverflow({ tokenContentKey, measureRef, measureTriggerRef, - fieldRef, - leadingIconRef, - inputWrapperRef, + tokensRef, + inputRef, + fieldWidth, layoutKey, -}: UseTokenOverflowOptions): UseTokenOverflowResult { - const [containerWidth, containerRef] = useContainerQuery(entry => entry.borderBoxWidth); +}: UseTokenOverflowOptions): number { const [visibleCount, setVisibleCount] = useState(tokenCount); useLayoutEffect(() => { - const field = fieldRef.current; + const tokens = tokensRef.current; + const input = inputRef.current; const measure = measureRef.current; - if (!enabled || !field || !measure || !containerWidth) { + if (measure) { + measure.inert = true; + } + if (!enabled || !tokens || !input || !measure || !fieldWidth) { 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 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, 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 = availableWidth - inputMinWidth; const totalWidth = tokenWidths.reduce((sum, width) => sum + width + gap, -gap); setVisibleCount( totalWidth <= budget ? tokenWidths.length : countFittingFromEnd(tokenWidths, budget - triggerWidth - gap, gap) ); - }, [ - enabled, - tokenCount, - tokenContentKey, - containerWidth, - layoutKey, - fieldRef, - measureRef, - measureTriggerRef, - leadingIconRef, - inputWrapperRef, - ]); + }, [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) { diff --git a/src/multiselect/__tests__/multiselect.test.tsx b/src/multiselect/__tests__/multiselect.test.tsx index af7e79ede1..47369ab3c3 100644 --- a/src/multiselect/__tests__/multiselect.test.tsx +++ b/src/multiselect/__tests__/multiselect.test.tsx @@ -1008,11 +1008,10 @@ describe('resize observer', () => { 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 d63ae63c43..64f1dddf2b 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');