diff --git a/pages/autosuggest/tokens-mode.page.tsx b/pages/autosuggest/tokens-mode.page.tsx new file mode 100644 index 0000000000..fc1159f76e --- /dev/null +++ b/pages/autosuggest/tokens-mode.page.tsx @@ -0,0 +1,244 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Autosuggest, AutosuggestProps, Box, Button, FormField, Icon, IconProvider, SpaceBetween } from '~components'; +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const ALL_OPTIONS: AutosuggestProps.Options = [ + { value: 'us-east-1' }, + { value: 'us-west-2' }, + { value: 'eu-west-1' }, + { value: 'ap-southeast-1' }, + { value: 'ca-central-1' }, + { value: 'sa-east-1' }, + { value: 'eu-central-1' }, + { value: 'ap-northeast-1' }, + { value: 'us-west-1' }, + { value: 'eu-north-1' }, + { value: 'ap-east-1' }, + { value: 'me-south-1' }, +]; + +const EMAIL_OPTIONS: AutosuggestProps.Options = [ + { value: 'alice@example.com' }, + { value: 'bob.smith@company.org' }, + { value: 'carol.jones@enterprise.io' }, + { value: 'dave@test.net' }, + { value: 'eve.long-surname@subdomain.example.com' }, + { value: 'frank@mail.co' }, + { value: 'grace@example.com' }, + { value: 'henry.wilson@corp.net' }, +]; + +function makeToken(label: string): AutosuggestProps.Token { + return { value: label }; +} + +const CUSTOM_SVG_ICON = ( + + + + +); + +const REGION_ICON_MAP: Record = { + 'us-east-1': , + 'us-west-2': , + 'eu-west-1': , + 'ap-southeast-1': , + 'ca-central-1': , + 'sa-east-1': , + 'custom-region': , +}; + +const ICON_OPTIONS: AutosuggestProps.Options = [ + { value: 'us-east-1', iconName: 'settings' }, + { value: 'us-west-2', iconName: 'globe' }, + { value: 'eu-west-1', iconName: 'calendar' }, + { value: 'ap-southeast-1', iconName: 'contact' }, + { value: 'ca-central-1', iconName: 'flag' }, + { value: 'sa-east-1', iconName: 'star' }, + { value: 'custom-region', label: 'custom-region (custom SVG icon)', iconSvg: CUSTOM_SVG_ICON }, +]; + +function makeIconToken(value: string): AutosuggestProps.Token { + const icon = REGION_ICON_MAP[value]; + return { value, ...(icon ? { icon } : {}) }; +} + +function IconScenario() { + const [value, setValue] = useState(''); + const [tokens, setTokens] = useState([ + makeIconToken('us-east-1'), + makeIconToken('eu-west-1'), + makeIconToken('ap-southeast-1'), + ]); + const selected = new Set(tokens.map(t => t.value)); + return ( + + { + setValue(detail.value); + if (detail.tokens !== undefined) { + setTokens( + detail.tokens.map(t => { + const existing = tokens.find(et => et.value === t.value); + if (existing?.icon) { + return existing; + } + const option = ICON_OPTIONS.find(o => o.value === t.value); + return option ? makeIconToken(t.value) : t; + }) + ); + } + }} + options={ICON_OPTIONS.filter(o => !selected.has(o.value!) && (!value || o.value!.includes(value)))} + placeholder="Search or add a region" + empty="No options" + /> + + ); +} + +function InteractiveDemo() { + const [value, setValue] = useState(''); + const [tokens, setTokens] = useState([]); + + const selected = new Set(tokens.map(t => t.value)); + const filteredOptions = ALL_OPTIONS.filter(o => !selected.has(o.value!) && (!value || o.value!.includes(value))); + + return ( + + Interactive demo + +
+ { + setValue(detail.value); + if (detail.tokens !== undefined) { + setTokens([...detail.tokens]); + } + }} + tokens={tokens} + i18nStrings={{ + enteredTextLabel: v => `Add "${v}"`, + tokenOverflowAriaLabel: count => `${count} more regions`, + tokenDismissLabel: v => `Remove ${v}`, + }} + options={filteredOptions} + placeholder="Search or add a region" + empty="No options" + /> +
+
+ + + + +
+ ); +} + +function Scenario({ + label, + initTokens, + options = ALL_OPTIONS, + disabled, + readOnly, + invalid, +}: { + label: string; + initTokens: AutosuggestProps.Token[]; + options?: AutosuggestProps.Options; + disabled?: boolean; + readOnly?: boolean; + invalid?: boolean; +}) { + const [value, setValue] = useState(''); + const [tokens, setTokens] = useState(initTokens); + return ( + + { + setValue(detail.value); + if (detail.tokens !== undefined) { + setTokens([...detail.tokens]); + } + }} + options={options.filter(o => !value || o.value!.includes(value))} + placeholder="Search or add a region" + empty="No options" + disabled={disabled} + readOnly={readOnly} + invalid={invalid} + /> + + ); +} + +export default function AutosuggestTokensModePage() { + return ( + + + Autosuggest — mode="tokens" + + + Scenarios + + + + + + + + + , + }} + > + + + makeToken(o.value!))} + /> + + + + + + + + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a3eedb253f..aa866f638b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -3088,11 +3088,23 @@ exports[`Components definition for autosuggest matches the snapshot: autosuggest }, { "cancelable": false, - "description": "Called whenever a user changes the input value (by typing or pasting). -The event \`detail\` contains the current value of the field.", + "description": "Called whenever the input value or token list changes. + +In default mode, fires on every keystroke with \`{ value }\`. +When \`tokens\` is provided, fires in three situations: +* Keystroke — \`value\` is the new text; \`tokens\` is unchanged. +* Token added — \`value\` is \`""\`; \`tokens\` contains the updated list. +* Token dismissed — \`value\` is unchanged; \`tokens\` contains the updated list. + +\`tokens\` is \`undefined\` when not in tokens mode.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "AutosuggestProps.ChangeDetail", "properties": [ + { + "name": "tokens", + "optional": true, + "type": "ReadonlyArray", + }, { "name": "value", "optional": false, @@ -3101,7 +3113,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "AutosuggestProps.ChangeDetail", "name": "onChange", }, { @@ -3615,6 +3627,7 @@ receive focus.", "type": "boolean", }, { + "deprecatedTag": "Use \`i18nStrings.enteredTextLabel\` instead.", "description": "Specifies a function that generates the custom value indicator (for example, \`Use "\${value}"\`).", "i18nTag": true, "inlineType": { @@ -3726,6 +3739,84 @@ Note: Manual filtering doesn't disable match highlighting.", "optional": true, "type": "boolean", }, + { + "description": "An object containing localized strings for the tokens mode UI. +Applies only when \`tokens\` is provided.", + "i18nTag": true, + "inlineType": { + "name": "AutosuggestProps.I18nStrings", + "properties": [ + { + "inlineType": { + "name": "(value: string) => string", + "parameters": [ + { + "name": "value", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "enteredTextLabel", + "optional": true, + "type": "((value: string) => string)", + }, + { + "inlineType": { + "name": "(value: string) => string", + "parameters": [ + { + "name": "value", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenDismissLabel", + "optional": true, + "type": "((value: string) => string)", + }, + { + "inlineType": { + "name": "(value: string) => string", + "parameters": [ + { + "name": "value", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenInsertedAriaLabel", + "optional": true, + "type": "((value: string) => string)", + }, + { + "inlineType": { + "name": "(hiddenCount: number) => string", + "parameters": [ + { + "name": "hiddenCount", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenOverflowAriaLabel", + "optional": true, + "type": "((hiddenCount: number) => string)", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "AutosuggestProps.I18nStrings", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -4176,6 +4267,16 @@ Refer to the [style](/components/autosuggest/?tabId=style) tab for more details. ], "type": "AutosuggestProps.Style", }, + { + "description": "Specifies the list of tokens to display as inline pills. +When this prop is provided (including an empty array), the component enters tokens mode: +accepted values accumulate as inline dismissible token pills before the text input. +Tokens that overflow the available width collapse into a \`+N\` pill. +This is a controlled prop — update it in response to \`onChange\`.", + "name": "tokens", + "optional": true, + "type": "ReadonlyArray", + }, { "description": "Specifies the text entered into the form element.", "name": "value", @@ -38155,6 +38256,40 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "description": "Returns the inline token at a 1-based index. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "name": "findInlineToken", + "parameters": [ + { + "description": "1-based index of the token to return.", + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "TokenWrapper", + }, + }, + { + "description": "Returns all visible inline token pills. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "name": "findInlineTokens", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "TokenWrapper", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.findNativeInput", @@ -38171,6 +38306,36 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "description": "Returns the overflow panel shown when the +N pill is clicked. +Returns \`null\` when the overflow panel is closed.", + "name": "findOverflowPanel", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the +N overflow pill shown when tokens overflow the available width. +Returns \`null\` when all tokens are visible or the component is not in tokens mode.", + "name": "findOverflowPill", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "InputWrapper.findPrefix", @@ -38226,6 +38391,22 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). ], }, }, + { + "description": "Returns the token trigger row — the container element that holds the overflow pill, +visible tokens, and text input when the component is in tokens mode. +Returns \`null\` when the component is not in tokens mode (the \`tokens\` prop is absent).", + "name": "findTokenTrigger", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.focus", @@ -38510,6 +38691,81 @@ Options get highlighted when they match the value of the input field.", ], "name": "AutosuggestDropdownWrapper", }, + { + "methods": [ + { + "description": "Returns the token description.", + "name": "findDescription", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the token dismiss button.", + "name": "findDismiss", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the token label.", + "name": "findLabel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the token label tag.", + "name": "findLabelTag", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the token tags.", + "name": "findTags", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "TokenWrapper", + }, { "methods": [], "name": "BadgeWrapper", @@ -43929,81 +44185,6 @@ wrapper.selectOptionByValue('option_1'); ], "name": "TokenGroupItemWrapper", }, - { - "methods": [ - { - "description": "Returns the token description.", - "name": "findDescription", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "description": "Returns the token dismiss button.", - "name": "findDismiss", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "description": "Returns the token label.", - "name": "findLabel", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "description": "Returns the token label tag.", - "name": "findLabelTag", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "description": "Returns the token tags.", - "name": "findTags", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "Array", - "typeArguments": [ - { - "name": "ElementWrapper", - }, - ], - }, - }, - ], - "name": "TokenWrapper", - }, { "methods": [ { @@ -44685,6 +44866,46 @@ and this method will have no effect.", ], }, }, + { + "description": "Returns the inline token at a 1-based index. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "inheritedFrom": { + "name": "AutosuggestWrapper.findInlineToken", + }, + "name": "findInlineToken", + "parameters": [ + { + "description": "1-based index of the token to return.", + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "TokenWrapper", + }, + }, + { + "description": "Returns all visible inline token pills. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "inheritedFrom": { + "name": "AutosuggestWrapper.findInlineTokens", + }, + "name": "findInlineTokens", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "TokenWrapper", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.findNativeInput", @@ -44701,6 +44922,42 @@ and this method will have no effect.", ], }, }, + { + "description": "Returns the overflow panel shown when the +N pill is clicked. +Returns \`null\` when the overflow panel is closed.", + "inheritedFrom": { + "name": "AutosuggestWrapper.findOverflowPanel", + }, + "name": "findOverflowPanel", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the +N overflow pill shown when tokens overflow the available width. +Returns \`null\` when all tokens are visible or the component is not in tokens mode.", + "inheritedFrom": { + "name": "AutosuggestWrapper.findOverflowPill", + }, + "name": "findOverflowPill", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "InputWrapper.findPrefix", @@ -44851,6 +45108,25 @@ and this method will have no effect.", ], }, }, + { + "description": "Returns the token trigger row — the container element that holds the overflow pill, +visible tokens, and text input when the component is in tokens mode. +Returns \`null\` when the component is not in tokens mode (the \`tokens\` prop is absent).", + "inheritedFrom": { + "name": "AutosuggestWrapper.findTokenTrigger", + }, + "name": "findTokenTrigger", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.focus", @@ -50456,6 +50732,40 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "description": "Returns the inline token at a 1-based index. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "name": "findInlineToken", + "parameters": [ + { + "description": "1-based index of the token to return.", + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "TokenWrapper", + }, + }, + { + "description": "Returns all visible inline token pills. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "name": "findInlineTokens", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "TokenWrapper", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.findNativeInput", @@ -50467,6 +50777,26 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "description": "Returns the overflow panel shown when the +N pill is clicked. +Returns \`null\` when the overflow panel is closed.", + "name": "findOverflowPanel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the +N overflow pill shown when tokens overflow the available width. +Returns \`null\` when all tokens are visible or the component is not in tokens mode.", + "name": "findOverflowPill", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "inheritedFrom": { "name": "InputWrapper.findPrefix", @@ -50509,6 +50839,17 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). "name": "ElementWrapper", }, }, + { + "description": "Returns the token trigger row — the container element that holds the overflow pill, +visible tokens, and text input when the component is in tokens mode. +Returns \`null\` when the component is not in tokens mode (the \`tokens\` prop is absent).", + "name": "findTokenTrigger", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "AutosuggestWrapper", }, @@ -50662,6 +51003,61 @@ Options get highlighted when they match the value of the input field.", ], "name": "AutosuggestDropdownWrapper", }, + { + "methods": [ + { + "description": "Returns the token description.", + "name": "findDescription", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the token dismiss button.", + "name": "findDismiss", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the token label.", + "name": "findLabel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the token label tag.", + "name": "findLabelTag", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the token tags.", + "name": "findTags", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "TokenWrapper", + }, { "methods": [], "name": "BadgeWrapper", @@ -54615,61 +55011,6 @@ The dismiss button is only rendered when the \`dismissible\` property is set to ], "name": "TokenGroupItemWrapper", }, - { - "methods": [ - { - "description": "Returns the token description.", - "name": "findDescription", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "description": "Returns the token dismiss button.", - "name": "findDismiss", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "description": "Returns the token label.", - "name": "findLabel", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "description": "Returns the token label tag.", - "name": "findLabelTag", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "description": "Returns the token tags.", - "name": "findTags", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "MultiElementWrapper", - "typeArguments": [ - { - "name": "ElementWrapper", - }, - ], - }, - }, - ], - "name": "TokenWrapper", - }, { "methods": [ { @@ -55131,6 +55472,46 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "description": "Returns the inline token at a 1-based index. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "inheritedFrom": { + "name": "AutosuggestWrapper.findInlineToken", + }, + "name": "findInlineToken", + "parameters": [ + { + "description": "1-based index of the token to return.", + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "TokenWrapper", + }, + }, + { + "description": "Returns all visible inline token pills. Only available when the component is in tokens mode +(the \`tokens\` prop is provided).", + "inheritedFrom": { + "name": "AutosuggestWrapper.findInlineTokens", + }, + "name": "findInlineTokens", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "TokenWrapper", + }, + ], + }, + }, { "inheritedFrom": { "name": "BaseInputWrapper.findNativeInput", @@ -55142,6 +55523,32 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "description": "Returns the overflow panel shown when the +N pill is clicked. +Returns \`null\` when the overflow panel is closed.", + "inheritedFrom": { + "name": "AutosuggestWrapper.findOverflowPanel", + }, + "name": "findOverflowPanel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the +N overflow pill shown when tokens overflow the available width. +Returns \`null\` when all tokens are visible or the component is not in tokens mode.", + "inheritedFrom": { + "name": "AutosuggestWrapper.findOverflowPill", + }, + "name": "findOverflowPill", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "inheritedFrom": { "name": "InputWrapper.findPrefix", @@ -55268,6 +55675,20 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "description": "Returns the token trigger row — the container element that holds the overflow pill, +visible tokens, and text input when the component is in tokens mode. +Returns \`null\` when the component is not in tokens mode (the \`tokens\` prop is absent).", + "inheritedFrom": { + "name": "AutosuggestWrapper.findTokenTrigger", + }, + "name": "findTokenTrigger", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "PropertyFilterWrapper", }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 7f63f4f3fe..70047ea514 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -434,11 +434,13 @@ exports[`test-utils selectors 1`] = ` "awsui_marker_1kjc7", "awsui_option_1p2cx", "awsui_options-list_19gcf", + "awsui_overflow-panel_1bi5g", "awsui_placeholder_18eso", "awsui_recovery_vrgzu", "awsui_root_11n0s", "awsui_root_15oj2", "awsui_root_1b522", + "awsui_root_1bi5g", "awsui_root_1fcus", "awsui_root_1kjc7", "awsui_root_1om0h", @@ -455,6 +457,10 @@ exports[`test-utils selectors 1`] = ` "awsui_ticks--y_f0fot", "awsui_title_1kjc7", "awsui_toggle_gfwv3", + "awsui_token-list-item_1bi5g", + "awsui_token-list_1bi5g", + "awsui_token-overflow-pill_1bi5g", + "awsui_token-trigger_1bi5g", "awsui_value_10ipo", "awsui_wrapper_1wepg", ], diff --git a/src/autosuggest/__integ__/autosuggest-tokens.test.ts b/src/autosuggest/__integ__/autosuggest-tokens.test.ts new file mode 100644 index 0000000000..4a478cf2de --- /dev/null +++ b/src/autosuggest/__integ__/autosuggest-tokens.test.ts @@ -0,0 +1,218 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import AutosuggestTokensPage from './page-objects/autosuggest-tokens-page'; + +jest.setTimeout(120_000); + +const URL = '/#/light/autosuggest/tokens-mode'; + +function setupTest(testFn: (page: AutosuggestTokensPage) => Promise) { + return useBrowser(async browser => { + const page = new AutosuggestTokensPage(browser); + await browser.url(URL); + await page.waitForPageReady(); + await testFn(page); + }); +} + +describe('Autosuggest tokens mode — adding tokens', () => { + test( + 'adds a token by pressing Enter after typing a value', + setupTest(async page => { + await page.typeAndAddToken('my-region'); + await page.assertTokenCount(1); + expect(await page.getTokenLabel(1)).toContain('my-region'); + }) + ); + + test( + 'clears the input after a token is added', + setupTest(async page => { + await page.typeAndAddToken('my-region'); + await expect(page.getInputValue()).resolves.toBe(''); + }) + ); + + test( + 'adds a token by selecting a suggestion from the dropdown', + setupTest(async page => { + await page.focusScopedInput(); + await page.keys(['us-east']); + await page.clickOption(1); + await page.assertTokenCount(1); + }) + ); + + test( + 'multiple tokens can be added sequentially', + setupTest(async page => { + await page.typeAndAddToken('region-a'); + await page.typeAndAddToken('region-b'); + await page.assertTokenCount(2); + }) + ); +}); + +describe('Autosuggest tokens mode — dismissing tokens', () => { + test( + 'dismisses a token when clicking its dismiss button', + setupTest(async page => { + await page.typeAndAddToken('to-remove'); + await page.assertTokenCount(1); + await page.clickTokenDismiss(1); + await page.assertTokenCount(0); + }) + ); + + test( + 'focuses the last token when Backspace is pressed on empty input', + setupTest(async page => { + await page.typeAndAddToken('region-a'); + await page.focusScopedInput(); + await page.keys(['Backspace']); + await page.keys(['Enter']); + await page.assertTokenCount(0); + }) + ); + + test( + 'dismisses last token using Delete key when token is focused', + setupTest(async page => { + await page.typeAndAddToken('region-a'); + await page.focusScopedInput(); + await page.keys(['Backspace']); + await page.keys(['Delete']); + await page.assertTokenCount(0); + }) + ); +}); + +describe('Autosuggest tokens mode — overflow pill', () => { + const overflowTokens = ['us-east-1', 'eu-west-1', 'ap-southeast-1', 'ca-central-1', 'sa-east-1', 'eu-central-1']; + + test( + 'shows overflow pill when tokens overflow the container', + setupTest(async page => { + for (const label of overflowTokens) { + await page.typeAndAddToken(label); + } + const pill = await page.getOverflowPillText(); + expect(pill).not.toBeNull(); + expect(pill).toMatch(/^\+\d+$/); + }) + ); + + test( + 'opens overflow dropdown when pill is clicked', + setupTest(async page => { + for (const label of overflowTokens) { + await page.typeAndAddToken(label); + } + await page.clickOverflowPill(); + await page.assertOverflowDropdownOpen(); + }) + ); + + test( + 'closes overflow dropdown on Escape', + setupTest(async page => { + for (const label of overflowTokens) { + await page.typeAndAddToken(label); + } + await page.clickOverflowPill(); + await page.assertOverflowDropdownOpen(); + await page.keys(['Escape']); + await page.assertOverflowDropdownOpen(false); + }) + ); +}); + +describe('Autosuggest tokens mode — clear button', () => { + test( + 'shows clear button when input has text', + setupTest(async page => { + await page.focusScopedInput(); + await page.keys(['h', 'i']); + expect(await page.isClearButtonVisible()).toBe(true); + }) + ); + + test( + 'no clear button when input is empty', + setupTest(async page => { + await page.waitForPageReady(); + expect(await page.isClearButtonVisible()).toBe(false); + }) + ); + + test( + 'clicking clear button empties the input', + setupTest(async page => { + await page.focusScopedInput(); + await page.keys(['h', 'i']); + await page.clickClearButton(); + await expect(page.getInputValue()).resolves.toBe(''); + }) + ); +}); + +describe('Autosuggest tokens mode — Backspace focus idempotency', () => { + test( + 'Backspace still focuses last token after delete → Tab back → Backspace cycle', + setupTest(async page => { + await page.typeAndAddToken('region-a'); + await page.typeAndAddToken('region-b'); + await page.focusScopedInput(); + await page.keys(['Backspace']); + await page.keys(['Delete']); + await page.assertTokenCount(1); + await page.keys(['Tab']); + await page.keys(['Backspace']); + await page.keys(['Enter']); + await page.assertTokenCount(0); + }) + ); +}); + +describe('Autosuggest tokens mode — keyboard navigation', () => { + test( + 'ArrowLeft from input focuses the last visible token', + setupTest(async page => { + await page.typeAndAddToken('region-a'); + await page.typeAndAddToken('region-b'); + await page.focusScopedInput(); + await page.keys(['Backspace']); + await page.keys(['Enter']); + await page.assertTokenCount(1); + expect(await page.getTokenLabel(1)).toContain('region-a'); + }) + ); + + test( + 'ArrowRight from last token returns focus to input', + setupTest(async page => { + await page.typeAndAddToken('region-a'); + await page.focusScopedInput(); + await page.keys(['Backspace']); + await page.keys(['ArrowRight']); + await page.keys(['x']); + await expect(page.getInputValue()).resolves.toBe('x'); + }) + ); + + test( + 'ArrowLeft navigates between adjacent tokens', + setupTest(async page => { + await page.typeAndAddToken('first'); + await page.typeAndAddToken('second'); + await page.focusScopedInput(); + await page.keys(['Backspace']); + await page.keys(['ArrowLeft']); + await page.keys(['Enter']); + await page.assertTokenCount(1); + expect(await page.getTokenLabel(1)).toContain('second'); + }) + ); +}); diff --git a/src/autosuggest/__integ__/page-objects/autosuggest-tokens-page.ts b/src/autosuggest/__integ__/page-objects/autosuggest-tokens-page.ts new file mode 100644 index 0000000000..2de79285fc --- /dev/null +++ b/src/autosuggest/__integ__/page-objects/autosuggest-tokens-page.ts @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import createWrapper from '../../../../lib/components/test-utils/selectors'; +import AutosuggestPage from './autosuggest-page'; + +export default class AutosuggestTokensPage extends AutosuggestPage { + get scope() { + return '[data-testid="interactive-demo"]'; + } + + private get scopedWrapper() { + return createWrapper(this.scope).findAutosuggest()!; + } + + async waitForPageReady() { + await this.waitForVisible(this.scopedInputSelector); + } + + getTokenCount(): Promise { + return this.getElementsCount(this.scopedWrapper.findInlineTokens().toSelector()); + } + + getTokenLabel(index: number): Promise { + return this.getText(this.scopedWrapper.findInlineToken(index)!.toSelector()); + } + + async clickTokenDismiss(index: number) { + await this.click(this.scopedWrapper.findInlineToken(index)!.findDismiss().toSelector()); + } + + async getOverflowPillText(): Promise { + const pillSelector = this.scopedWrapper.findOverflowPill()!.toSelector(); + const exists = await this.isExisting(pillSelector); + if (!exists) { + return null; + } + return this.getText(pillSelector); + } + + async clickOverflowPill() { + await this.click(this.scopedWrapper.findOverflowPill()!.toSelector()); + } + + async assertOverflowDropdownOpen(isOpen = true) { + await this.waitForVisible(this.scopedWrapper.findOverflowPanel()!.toSelector(), isOpen); + } + + get scopedInputSelector() { + return `${this.scope} input[role="combobox"]`; + } + + getInputValue(): Promise { + return this.getValue(this.scopedInputSelector); + } + + async focusScopedInput() { + await this.click(this.scopedInputSelector); + } + + async assertTokenCount(expected: number) { + await this.browser.waitUntil(async () => (await this.getTokenCount()) === expected, { + timeout: 5000, + timeoutMsg: `Expected ${expected} tokens`, + }); + } + + async typeAndAddToken(text: string) { + await this.focusScopedInput(); + await this.keys(text.split('')); + await this.assertDropdownOpen(true); + await this.keys(['Enter']); + await this.browser.waitUntil(async () => (await this.getInputValue()) === '', { + timeout: 10000, + timeoutMsg: `Input did not clear after adding token "${text}"`, + }); + await this.keys(['Escape']); + } + + get clearButtonSelector() { + return `${this.scope} [class*="input-icon-end"] button`; + } + + async clickClearButton() { + await this.click(this.clearButtonSelector); + } + + isClearButtonVisible() { + return this.isExisting(this.clearButtonSelector); + } +} diff --git a/src/autosuggest/__tests__/autosuggest-tokens-mode.test.tsx b/src/autosuggest/__tests__/autosuggest-tokens-mode.test.tsx new file mode 100644 index 0000000000..ed621306f8 --- /dev/null +++ b/src/autosuggest/__tests__/autosuggest-tokens-mode.test.tsx @@ -0,0 +1,285 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { act, render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Autosuggest, { AutosuggestProps } from '../../../lib/components/autosuggest'; +import TestI18nProvider from '../../../lib/components/i18n/testing'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +const defaultOptions: AutosuggestProps.Options = [ + { value: 'us-east-1', label: 'US East (N. Virginia)' }, + { value: 'eu-west-1', label: 'Europe (Ireland)' }, +]; + +function makeToken(value: string): AutosuggestProps.Token { + return { value }; +} + +function StatefulAutosuggest(props: Partial & { initTokens?: AutosuggestProps.Token[] }) { + const { initTokens = [], ...rest } = props; + const [value, setValue] = useState(''); + const [tokens, setTokens] = useState>(initTokens); + + return ( + { + setValue(detail.value); + if (detail.tokens !== undefined) { + setTokens([...detail.tokens]); + } + }} + tokens={tokens} + options={defaultOptions.filter(o => !value || o.value!.includes(value))} + {...rest} + /> + ); +} + +function renderStateful(props?: Partial & { initTokens?: AutosuggestProps.Token[] }) { + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + return { container, wrapper }; +} + +describe('Autosuggest tokens mode', () => { + test('selecting a dropdown option adds it as a token and clears the input', () => { + const { wrapper } = renderStateful(); + + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + + expect(wrapper.findNativeInput().getElement()).toHaveValue(''); + }); + + test('selecting a dropdown option fires onChange with the new token', () => { + const onChange = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + tokens: [{ value: 'us-east-1' }], + }), + }) + ); + }); + + test('selecting a dropdown option fires onChange with empty string to clear input', () => { + const onChange = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: expect.objectContaining({ value: '' }) })); + }); + + test('existing tokens are preserved when a new one is selected', () => { + const onChange = jest.fn(); + const existing = [makeToken('eu-west-1')]; + const { container } = render( + + ); + const wrapper = createWrapper(container).findAutosuggest()!; + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.down)); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + tokens: [makeToken('eu-west-1'), { value: 'us-east-1' }], + }), + }) + ); + }); + + test('entered-text label uses i18n catalog default (Add) in tokens mode when provided via I18nProvider', () => { + const { container } = render( + + + + ); + const wrapper = createWrapper(container).findAutosuggest()!; + act(() => wrapper.setInputValue('my-region')); + const enteredOption = wrapper.findEnteredTextOption(); + expect(enteredOption!.getElement()).toHaveTextContent('Add "my-region"'); + }); + + test('entered-text option has no text content when no enteredTextLabel prop or i18n provider is present', () => { + const { wrapper } = renderStateful(); + act(() => wrapper.setInputValue('my-region')); + expect(wrapper.findEnteredTextOption()?.getElement()).toHaveTextContent(''); + }); + + test('explicit enteredTextLabel prop overrides the tokens-mode default', () => { + const { wrapper } = renderStateful({ enteredTextLabel: v => `Use: "${v}"` }); + act(() => wrapper.setInputValue('my-region')); + const enteredOption = wrapper.findEnteredTextOption(); + expect(enteredOption!.getElement()).toHaveTextContent('Use: "my-region"'); + }); + + test('pressing Enter adds a token and clears the input', () => { + const { wrapper } = renderStateful(); + act(() => wrapper.setInputValue('ap-southeast-1')); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + expect(wrapper.findNativeInput().getElement()).toHaveValue(''); + }); +}); + +describe('Autosuggest tokens mode — test-utils selectors', () => { + test('findTokenTrigger returns the token row container when tokens prop is provided', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1')] }); + expect(wrapper.findTokenTrigger()).not.toBeNull(); + }); + + test('findTokenTrigger returns null when tokens prop is absent', () => { + const { container } = render( undefined} options={defaultOptions} />); + const wrapper = createWrapper(container).findAutosuggest()!; + expect(wrapper.findTokenTrigger()).toBeNull(); + }); + + test('findInlineTokens returns all visible tokens', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1'), makeToken('eu-west-1')] }); + expect(wrapper.findInlineTokens()).toHaveLength(2); + }); + + test('findInlineTokens returns empty array when no tokens', () => { + const { wrapper } = renderStateful({ initTokens: [] }); + expect(wrapper.findInlineTokens()).toHaveLength(0); + }); + + test('findInlineToken returns the token at a 1-based index', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1'), makeToken('eu-west-1')] }); + expect(wrapper.findInlineToken(1)!.getElement()).toHaveTextContent('us-east-1'); + expect(wrapper.findInlineToken(2)!.getElement()).toHaveTextContent('eu-west-1'); + }); + + test('findInlineToken returns null for an out-of-range index', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1')] }); + expect(wrapper.findInlineToken(5)).toBeNull(); + }); + + test('findOverflowPill returns null when all tokens are visible', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1')] }); + expect(wrapper.findOverflowPill()).toBeNull(); + }); + + test('findOverflowPanel returns null when the overflow panel is not open', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1')] }); + expect(wrapper.findOverflowPanel()).toBeNull(); + }); +}); + +describe('Autosuggest tokens mode — role=group', () => { + test('root element has role=group in tokens mode', () => { + const { wrapper } = renderStateful({ initTokens: [makeToken('us-east-1')] }); + expect(wrapper.getElement().getAttribute('role')).toBe('group'); + }); + + test('root element does not have role=group in default mode', () => { + const { container } = render( undefined} options={defaultOptions} />); + const wrapper = createWrapper(container).findAutosuggest()!; + expect(wrapper.getElement().getAttribute('role')).not.toBe('group'); + }); + + test('group has aria-label from ariaLabel prop', () => { + const { wrapper } = renderStateful({ initTokens: [], ariaLabel: 'Region selector' }); + expect(wrapper.getElement()).toHaveAttribute('aria-label', 'Region selector'); + }); +}); + +describe('Autosuggest tokens mode — a11y: tab order', () => { + test('first visible token dismiss button has tabIndex=0 (is in Tab sequence)', () => { + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + const firstBtn = wrapper.getElement().querySelector('[data-token-item] button'); + expect(firstBtn!.tabIndex).toBe(0); + }); + + test('non-first token dismiss buttons have tabIndex=-1', () => { + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + const allBtns = Array.from(wrapper.getElement().querySelectorAll('[data-token-item] button')); + expect(allBtns[1].tabIndex).toBe(-1); + }); + + test('tokenDismissLabel i18n prop sets dismiss button aria-label', () => { + const { container } = render( + `Remove region ${v}` }} + /> + ); + const wrapper = createWrapper(container).findAutosuggest()!; + const dismissBtn = wrapper.findInlineToken(1)!.findDismiss()!.getElement(); + expect(dismissBtn).toHaveAttribute('aria-label', 'Remove region us-east-1'); + }); + + test('no tokens: input is the only Tab stop', () => { + const { wrapper } = renderStateful({ initTokens: [] }); + const allTabStops = Array.from( + wrapper.getElement().querySelectorAll('[tabindex="0"], input:not([tabindex="-1"])') + ); + const tagNames = allTabStops.map(el => el.tagName.toLowerCase()); + expect(tagNames.every(t => t === 'input')).toBe(true); + }); +}); + +describe('Autosuggest tokens mode — tokenInsertedAriaLabel announcement', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + test('live region is present in token mode', () => { + renderStateful(); + expect(document.querySelector('[aria-live="assertive"]')).not.toBeNull(); + }); + + test('custom tokenInsertedAriaLabel prop is used for the announcement text', () => { + const { wrapper } = renderStateful({ + i18nStrings: { tokenInsertedAriaLabel: (v: string) => `Inserted: ${v}` }, + }); + + act(() => wrapper.setInputValue('us-east-1')); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + act(() => { + jest.runAllTimers(); + }); + + const liveRegion = document.querySelector('[aria-live="assertive"]'); + expect(liveRegion?.textContent).toContain('Inserted: us-east-1'); + }); + + test('announcement text falls back to the token label when no tokenInsertedAriaLabel prop or i18n provider is present', () => { + const { wrapper } = renderStateful(); + + act(() => wrapper.setInputValue('eu-west-1')); + act(() => wrapper.findNativeInput().keydown(KeyCode.enter)); + act(() => { + jest.runAllTimers(); + }); + + const liveRegion = document.querySelector('[aria-live="assertive"]'); + expect(liveRegion?.textContent).toContain('eu-west-1'); + }); +}); diff --git a/src/autosuggest/__tests__/autosuggest.test.tsx b/src/autosuggest/__tests__/autosuggest.test.tsx index b0634c2e3f..96af837222 100644 --- a/src/autosuggest/__tests__/autosuggest.test.tsx +++ b/src/autosuggest/__tests__/autosuggest.test.tsx @@ -692,3 +692,38 @@ describe('native attributes', () => { ); }); }); + +describe('tokens mode', () => { + const tokenProps: Partial = { + tokens: [], + }; + + it('renders without errors with tokens props', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('does not pass tokens to the underlying input in default mode', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('fires onChange with tokens when a dropdown option is selected in tokens mode', () => { + const onChange = jest.fn(); + const { container } = render( + `Add "${v}"`} + value="1" + /> + ); + const wrapper = createWrapper(container).findAutosuggest()!; + wrapper.findNativeInput().keydown(KeyCode.down); + wrapper.findNativeInput().keydown(KeyCode.down); + wrapper.findNativeInput().keydown(KeyCode.enter); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: '', tokens: [{ value: '1' }] } })); + }); +}); diff --git a/src/autosuggest/__tests__/i18n.test.tsx b/src/autosuggest/__tests__/i18n.test.tsx index 21e5caf251..9b1067284e 100644 --- a/src/autosuggest/__tests__/i18n.test.tsx +++ b/src/autosuggest/__tests__/i18n.test.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import * as React from 'react'; -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; @@ -101,37 +101,222 @@ describe('i18n provider', () => { ).toHaveTextContent('Custom selected'); }); - const enteredTextLabelRequiredWarning = 'A value for enteredTextLabel must be provided.'; - - test('should warn when enteredTextLabel is undefined and not using the i18n provider', () => { - renderElement(); - expect(warnOnce).toHaveBeenCalledWith('Autosuggest', enteredTextLabelRequiredWarning); + test('should not show entered-text option when enteredTextLabel is undefined and not using the i18n provider', () => { + const { wrapper } = renderElement(); + wrapper.setInputValue('test'); + expect(wrapper.findEnteredTextOption()?.getElement()).toHaveTextContent(''); }); - test('should warn when enteredTextLabel is undefined when using the i18n provider without enteredTextLabel value', () => { - renderElement( + test('should not show entered-text option when enteredTextLabel is undefined when using the i18n provider without enteredTextLabel value', () => { + const { wrapper } = renderElement( ); - expect(warnOnce).toHaveBeenCalledWith('Autosuggest', enteredTextLabelRequiredWarning); + wrapper.setInputValue('test'); + expect(wrapper.findEnteredTextOption()?.getElement()).toHaveTextContent(''); }); - test('should warn when enteredTextLabel is undefined when using the i18n provider with empty enteredTextLabel value', () => { - renderElement( + test('should not show entered-text option when enteredTextLabel is undefined when using the i18n provider with empty enteredTextLabel value', () => { + const { wrapper } = renderElement( ); - expect(warnOnce).toHaveBeenCalledWith('Autosuggest', enteredTextLabelRequiredWarning); + wrapper.setInputValue('test'); + expect(wrapper.findEnteredTextOption()?.getElement()).toHaveTextContent(''); }); - test('should not warn when enteredTextLabel is undefined when using the i18n provider', () => { - renderElement( + test('should show entered-text option when enteredTextLabel is provided via i18n provider', () => { + const { wrapper } = renderElement( ); + wrapper.setInputValue('test'); + expect(wrapper.findEnteredTextOption()?.getElement()).toHaveTextContent('Use'); + }); + + test('supports providing enteredTextLabel via i18n provider in tokens mode', () => { + const { wrapper } = renderElement( + + + + ); + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Custom add value'); + }); + + test('should not warn when tokens mode and enteredTextLabel is provided via i18n provider', () => { + renderElement( + + + + ); expect(warnOnce).not.toHaveBeenCalled(); }); + + test("supports providing enteredTextLabel via i18n provider dotted key 'i18nStrings.enteredTextLabel'", () => { + const { wrapper } = renderElement( + + + + ); + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Dotted key value'); + }); + + test("dotted key 'i18nStrings.enteredTextLabel' wins over flat 'enteredTextLabel' in provider", () => { + const { wrapper } = renderElement( + + + + ); + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Dotted key'); + }); + + test("should not warn when 'i18nStrings.enteredTextLabel' dotted key is provided via i18n provider", () => { + renderElement( + + + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('i18nStrings.enteredTextLabel direct prop is used in tokens mode', () => { + function TokensAutosuggest(props: Partial) { + const [value, setValue] = React.useState('1'); + return ( + setValue(detail.value)} + tokens={[]} + i18nStrings={{ enteredTextLabel: v => `Add tag "${v}"` }} + /> + ); + } + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Add tag "S"'); + }); + + test('i18nStrings.enteredTextLabel direct prop is used in default mode', () => { + function DefaultAutosuggest() { + const [value, setValue] = React.useState('1'); + return ( + setValue(detail.value)} + i18nStrings={{ enteredTextLabel: v => `Search for "${v}"` }} + /> + ); + } + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Search for "S"'); + }); + + test('i18nStrings.enteredTextLabel takes priority over deprecated enteredTextLabel prop in tokens mode', () => { + function OverrideAutosuggest() { + const [value, setValue] = React.useState('1'); + return ( + setValue(detail.value)} + tokens={[]} + enteredTextLabel={v => `Use "${v}"`} + i18nStrings={{ enteredTextLabel: v => `Add "${v}"` }} + /> + ); + } + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + wrapper.setInputValue('S'); + expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Add "S"'); + }); + + test('supports providing i18nStrings.tokenInsertedAriaLabel via i18n provider dotted key', () => { + jest.useFakeTimers(); + function TokensAutosuggest() { + const [value, setValue] = React.useState(''); + const [tokens, setTokens] = React.useState([]); + return ( + + { + setValue(detail.value); + if (detail.tokens !== undefined) { + setTokens([...detail.tokens] as AutosuggestProps.Token[]); + } + }} + /> + + ); + } + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + + wrapper.setInputValue('my-region'); + act(() => { + wrapper.findNativeInput().keydown(KeyCode.enter); + }); + act(() => { + jest.runAllTimers(); + }); + jest.useRealTimers(); + + const liveRegion = document.querySelector('[aria-live="assertive"]'); + expect(liveRegion?.textContent).toContain('my-region was inserted'); + }); + + test('i18nStrings.tokenInsertedAriaLabel direct prop takes effect in tokens mode', () => { + jest.useFakeTimers(); + function TokensAutosuggest() { + const [value, setValue] = React.useState(''); + const [tokens, setTokens] = React.useState([]); + return ( + `Added: ${v}` }} + onChange={({ detail }) => { + setValue(detail.value); + if (detail.tokens !== undefined) { + setTokens([...detail.tokens] as AutosuggestProps.Token[]); + } + }} + /> + ); + } + const { container } = render(); + const wrapper = createWrapper(container).findAutosuggest()!; + + wrapper.setInputValue('us-east-1'); + act(() => { + wrapper.findNativeInput().keydown(KeyCode.enter); + }); + act(() => { + jest.runAllTimers(); + }); + jest.useRealTimers(); + + const liveRegion = document.querySelector('[aria-live="assertive"]'); + expect(liveRegion?.textContent).toContain('Added: us-east-1'); + }); }); diff --git a/src/autosuggest/__tests__/options-controller.test.ts b/src/autosuggest/__tests__/options-controller.test.ts index aa9f51860d..f20d4fb758 100644 --- a/src/autosuggest/__tests__/options-controller.test.ts +++ b/src/autosuggest/__tests__/options-controller.test.ts @@ -222,4 +222,68 @@ describe('useAutosuggestItems', () => { expect(selectOption).not.toHaveBeenCalled(); }); }); + + describe('tokens mode enteredTextLabel', () => { + test('uses enteredTextLabel (shows "Add" text in tokens mode)', () => { + const { result } = renderHook(useAutosuggestItems, { + initialProps: { + ...defaultProps, + filterValue: 'us-east', + filterText: 'us-east', + enteredTextLabel: (v: string) => `Add "${v}"`, + }, + }); + const enteredItem = result.current[0].items[0]; + expect(enteredItem.type).toBe('use-entered'); + expect(enteredItem.label).toBe('Add "us-east"'); + }); + + test('uses enteredTextLabel in default mode', () => { + const { result } = renderHook(useAutosuggestItems, { + initialProps: { + ...defaultProps, + filterValue: 'us-east', + filterText: 'us-east', + enteredTextLabel: (v: string) => `Use: ${v}`, + }, + }); + const enteredItem = result.current[0].items[0]; + expect(enteredItem.type).toBe('use-entered'); + expect(enteredItem.label).toBe('Use: us-east'); + }); + + test('filtering behaviour is unchanged — options are still filtered', () => { + const { result } = renderHook(useAutosuggestItems, { + initialProps: { + ...defaultProps, + filterValue: 'Option 1', + filterText: 'Option 1', + enteredTextLabel: (v: string) => `Add "${v}"`, + }, + }); + const types = result.current[0].items.map(i => i.type); + expect(types[0]).toBe('use-entered'); + const nonEntered = result.current[0].items.slice(1); + expect(nonEntered.length).toBeGreaterThan(0); + nonEntered.forEach(item => { + if (item.value) { + expect(item.value).toContain('Option 1'); + } + }); + }); + test('uses isTokenMode=false (Use: text) when isTokenMode is undefined — property-filter path', () => { + const { result } = renderHook(useAutosuggestItems, { + initialProps: { + ...defaultProps, + filterValue: 'us-east', + filterText: 'us-east', + // isTokenMode not passed (undefined) — simulates property-filter calling useAutosuggestItems directly + }, + }); + const enteredItem = result.current[0].items[0]; + expect(enteredItem.type).toBe('use-entered'); + // label is undefined when no enteredTextLabel prop and no i18n provider + expect(enteredItem.label).toBeUndefined(); + }); + }); }); diff --git a/src/autosuggest/interfaces.ts b/src/autosuggest/interfaces.ts index 4141cf719f..bc00a289ba 100644 --- a/src/autosuggest/interfaces.ts +++ b/src/autosuggest/interfaces.ts @@ -110,6 +110,7 @@ export interface AutosuggestProps /** * Specifies a function that generates the custom value indicator (for example, `Use "${value}"`). + * @deprecated Use `i18nStrings.enteredTextLabel` instead. * @i18n */ enteredTextLabel?: AutosuggestProps.EnteredTextLabel; @@ -165,10 +166,47 @@ export interface AutosuggestProps * @awsuiSystem core */ style?: AutosuggestProps.Style; + + /** + * Specifies the list of tokens to display as inline pills. + * When this prop is provided (including an empty array), the component enters tokens mode: + * accepted values accumulate as inline dismissible token pills before the text input. + * Tokens that overflow the available width collapse into a `+N` pill. + * This is a controlled prop — update it in response to `onChange`. + */ + tokens?: ReadonlyArray; + + /** + * Called whenever the input value or token list changes. + * + * In default mode, fires on every keystroke with `{ value }`. + * When `tokens` is provided, fires in three situations: + * * Keystroke — `value` is the new text; `tokens` is unchanged. + * * Token added — `value` is `""`; `tokens` contains the updated list. + * * Token dismissed — `value` is unchanged; `tokens` contains the updated list. + * + * `tokens` is `undefined` when not in tokens mode. + */ + onChange?: NonCancelableEventHandler; + + /** + * An object containing localized strings for the tokens mode UI. + * Applies only when `tokens` is provided. + * @i18n + */ + i18nStrings?: AutosuggestProps.I18nStrings; } export namespace AutosuggestProps { - export type ChangeDetail = InputProps.ChangeDetail; + export interface ChangeDetail { + /** Current text value in the input field. */ + value: string; + /** + * Current token list. Only present when `tokens` prop is provided. + * `undefined` in default mode — existing consumers are unaffected. + */ + tokens?: ReadonlyArray; + } export type KeyDetail = InputProps.KeyDetail; export type FilteringType = OptionsFilteringType; export type Option = OptionDefinition; @@ -227,6 +265,63 @@ export namespace AutosuggestProps { select(): void; } + export interface Token { + /** Specifies the text value displayed in the token pill. */ + value: string; + /** + * An icon displayed at the start of the token. + * + * Use an `` element. Icon size should be `small` when `variant="inline"`. + */ + icon?: React.ReactNode; + } + + export interface I18nStrings { + /** + * Label for the "entered text" dropdown item shown when the user types a value. + * In default mode the item reads `Use: "{value}"` by default. + * In tokens mode it reads `Add: "{value}"` by default. + * + * Since you control whether `tokens` is provided, you can branch on your + * own state to return different text per mode: + * `(value) => tokens !== undefined ? \`Add "${value}"\` : \`Use "${value}"\`` + * + * Replaces the deprecated top-level `enteredTextLabel` prop. + * @i18n + */ + enteredTextLabel?: (value: string) => string; + + /** + * Provides an aria-label for the `+N` overflow pill button shown when tokens overflow. + * The function receives the count of hidden items. + * + * Use to provide context-specific vocabulary: + * `count => \`${count} more regions\`` + * + * Applies only when `tokens` is provided. + * @i18n + */ + tokenOverflowAriaLabel?: (hiddenCount: number) => string; + + /** + * Announced via an `aria-live` region when a token is added. + * The function receives the token value. + * + * Applies only when `tokens` is provided. + * @i18n + */ + tokenInsertedAriaLabel?: (value: string) => string; + + /** + * Provides an `aria-label` for the dismiss button on each token pill. + * The function receives the token value. + * + * Applies only when `tokens` is provided. + * @i18n + */ + tokenDismissLabel?: (value: string) => string; + } + export interface Style { root?: { backgroundColor?: { diff --git a/src/autosuggest/internal.tsx b/src/autosuggest/internal.tsx index b10e46f747..15ff7332c3 100644 --- a/src/autosuggest/internal.tsx +++ b/src/autosuggest/internal.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { Ref, useImperativeHandle, useRef } from 'react'; +import React, { Ref, useImperativeHandle, useRef, useState } from 'react'; import clsx from 'clsx'; import { useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -58,6 +58,8 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r renderHighlightedAriaLive, style, renderOption, + tokens, + i18nStrings, __internalRootRef, ...restProps } = props; @@ -80,22 +82,48 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r const selectedAriaLabel = i18n('selectedAriaLabel', restProps.selectedAriaLabel); const recoveryText = i18n('recoveryText', restProps.recoveryText); + const isTokenMode = tokens !== undefined; + if (restProps.recoveryText && !onLoadItems) { warnOnce('Autosuggest', '`onLoadItems` must be provided for `recoveryText` to be displayed.'); } + const resolvedTokenOverflowAriaLabel = + i18nStrings?.tokenOverflowAriaLabel ?? + i18n('i18nStrings.tokenOverflowAriaLabel', undefined, format => (n: number) => format({ count: String(n) })); + + const resolvedTokenInsertedAriaLabel = + i18nStrings?.tokenInsertedAriaLabel ?? + i18n('i18nStrings.tokenInsertedAriaLabel', undefined, format => (v: string) => format({ token__label: v })); + + const resolvedTokenDismissLabel = + i18nStrings?.tokenDismissLabel ?? + i18n('i18nStrings.tokenDismissLabel', undefined, format => (v: string) => format({ token__label: v })); + + const [tokenInsertedAriaLabelState, setTokenInsertedAriaLabelState] = useState(undefined); + const [autosuggestItemsState, autosuggestItemsHandlers] = useAutosuggestItems({ options: options || [], filterValue: value, filterText: value, filteringType, - enteredTextLabel, + enteredTextLabel: i18nStrings?.enteredTextLabel ?? enteredTextLabel, hideEnteredTextLabel: hideEnteredTextOption, + isTokenMode: isTokenMode, onSelectItem: (option: AutosuggestItem) => { - const value = option.value || ''; - fireNonCancelableEvent(onChange, { value }); + const selectedValue = option.value || ''; + if (isTokenMode && selectedValue) { + const currentTokens = tokens ?? []; + const updated = [...currentTokens, { value: selectedValue }]; + setTokenInsertedAriaLabelState( + resolvedTokenInsertedAriaLabel ? resolvedTokenInsertedAriaLabel(selectedValue) : selectedValue + ); + fireNonCancelableEvent(onChange, { value: '', tokens: updated }); + } else { + fireNonCancelableEvent(onChange, { value: selectedValue }); + } fireNonCancelableEvent(onSelect, { - value, + value: selectedValue, selectedOption: option.type !== 'use-entered' ? option.option : undefined, }); autosuggestInputRef.current?.close(); @@ -185,9 +213,10 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r const isEmpty = !value && !autosuggestItemsState.items.length; const isFiltered = !!value && value.length !== 0 && !(filteringType === 'auto' && autosuggestItemsState.showAll); - const filteredText = isFiltered - ? filteringResultsText?.(autosuggestItemsState.items.length, options?.length ?? 0) - : undefined; + const filteredText = + isFiltered && !isTokenMode + ? filteringResultsText?.(autosuggestItemsState.items.length, options?.length ?? 0) + : undefined; const dropdownStatus = useDropdownStatus({ ...props, isEmpty, @@ -231,6 +260,10 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r ariaActivedescendant={highlightedOptionId} dropdownExpanded={shouldRenderDropdownContent} style={style} + tokens={tokens} + tokenOverflowAriaLabel={resolvedTokenOverflowAriaLabel} + tokenInsertedAriaLabel={tokenInsertedAriaLabelState} + tokenDismissLabel={resolvedTokenDismissLabel} dropdownContent={ shouldRenderDropdownContent && ( void; + isTokenMode?: boolean; } export interface AutosuggestItemsState extends HighlightedOptionState { @@ -54,19 +53,17 @@ export const useAutosuggestItems = ({ enteredTextLabel, hideEnteredTextLabel, onSelectItem, + isTokenMode, }: UseAutosuggestItemsProps): [AutosuggestItemsState, AutosuggestItemsHandlers] => { const i18n = useInternalI18n('autosuggest'); const [showAll, setShowAll] = useState(false); const { items, getItemGroup, getItemParent } = useMemo(() => createItems(options), [options]); - const enteredItemLabel = i18n('enteredTextLabel', enteredTextLabel?.(filterValue), format => - format({ value: filterValue }) - ); - - if (!enteredItemLabel) { - warnOnce('Autosuggest', 'A value for enteredTextLabel must be provided.'); - } + const enteredItemLabel = + i18n('i18nStrings.enteredTextLabel', enteredTextLabel?.(filterValue), format => + format({ value: filterValue, isTokenMode: String(isTokenMode ?? false) }) + ) ?? i18n('enteredTextLabel', enteredTextLabel?.(filterValue), format => format({ value: filterValue })); const filteredItems = useMemo(() => { const filteredItems = filteringType === 'auto' && !showAll ? filterOptions(items, filterText) : [...items]; diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 073a72b5c3..36d9f28924 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -66,6 +66,19 @@ export interface I18nFormatArgTypes { "value": string | number; } "errorIconAriaLabel": never; + "i18nStrings.enteredTextLabel": { + "isTokenMode": string; + "value": string | number; + } + "i18nStrings.tokenInsertedAriaLabel": { + "token__label": string | number; + } + "i18nStrings.tokenOverflowAriaLabel": { + "count": string; + } + "i18nStrings.tokenDismissLabel": { + "token__label": string | number; + } "recoveryText": never; "selectedAriaLabel": never; } diff --git a/src/i18n/messages/all.de.json b/src/i18n/messages/all.de.json index bfaf7f14f3..f23f7b166e 100644 --- a/src/i18n/messages/all.de.json +++ b/src/i18n/messages/all.de.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "„{value}“ verwenden", "errorIconAriaLabel": "Fehler", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {„{value}“ verwenden} true {„{value}“ hinzufügen} other {}}", + "i18nStrings.tokenDismissLabel": "{token__label} entfernen", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} eingefügt", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 weiterer Artikel} other {{count} weitere Artikel}}", "recoveryText": "Wiederholen", "selectedAriaLabel": "Ausgewählt" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Schritt {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Absenden des Formulars" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.en-GB.json b/src/i18n/messages/all.en-GB.json index d0d277d432..562efa3f54 100644 --- a/src/i18n/messages/all.en-GB.json +++ b/src/i18n/messages/all.en-GB.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Use: ‘{value}’", "errorIconAriaLabel": "Error", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Use: ‘{value}’} true {Add \"{value}\"} other {}}", + "i18nStrings.tokenDismissLabel": "Remove {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} inserted", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 more item} other {{count} more items}}", "recoveryText": "Retry", "selectedAriaLabel": "Selected" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Step {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Submitting form" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index 54c344e263..8a0f671cd3 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Use: \"{value}\"", "errorIconAriaLabel": "Error", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Use: \"{value}\"} true {Add \"{value}\"} other {}}", + "i18nStrings.tokenDismissLabel": "Remove {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} inserted", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 more item} other {{count} more items}}", "recoveryText": "Retry", "selectedAriaLabel": "Selected" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Step {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Submitting form" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.es.json b/src/i18n/messages/all.es.json index bb71036a07..e26efb5b0c 100644 --- a/src/i18n/messages/all.es.json +++ b/src/i18n/messages/all.es.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Utilizar: “{value}”", "errorIconAriaLabel": "Error", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Usar: “{value}”} true {Agregar “{value}”} other {}}", + "i18nStrings.tokenDismissLabel": "Eliminar {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} insertado", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 elemento más} other {{count} elementos más}}", "recoveryText": "Reintentar", "selectedAriaLabel": "Seleccionado" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Paso {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Formulario de envío" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.fr.json b/src/i18n/messages/all.fr.json index b6311f2888..7e29b013da 100644 --- a/src/i18n/messages/all.fr.json +++ b/src/i18n/messages/all.fr.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Utiliser : «{value}»", "errorIconAriaLabel": "Erreur", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Utiliser : « {value} »} true {Ajouter « {value} »} other {}}", + "i18nStrings.tokenDismissLabel": "Supprimer {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} insérée", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 article de plus} other {{count} articles de plus}}", "recoveryText": "Réessayer", "selectedAriaLabel": "Sélectionné" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Étape {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Soumission du formulaire" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.id.json b/src/i18n/messages/all.id.json index a2a69fd593..9eb6843365 100644 --- a/src/i18n/messages/all.id.json +++ b/src/i18n/messages/all.id.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Gunakan: \"{value}\"", "errorIconAriaLabel": "Kesalahan", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Gunakan: \"{value}\"} true {Tambahkan \"{value}\"} other {}}", + "i18nStrings.tokenDismissLabel": "Hapus {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} disisipkan", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 item lagi} other {{count} item lagi}}", "recoveryText": "Coba lagi", "selectedAriaLabel": "Dipilih" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Langkah {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Mengirimkan formulir" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.it.json b/src/i18n/messages/all.it.json index 71a3546db6..1b13940517 100644 --- a/src/i18n/messages/all.it.json +++ b/src/i18n/messages/all.it.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Utilizza: «{value}»", "errorIconAriaLabel": "Errore", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Usa: “{value}”} true {Aggiungi “{value}”} other {}}", + "i18nStrings.tokenDismissLabel": "Rimuovi {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} inserito", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 altro elemento} other {{count} altri elementi}}", "recoveryText": "Riprova", "selectedAriaLabel": "Selezionato" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Fase {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Modulo di invio" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.ja.json b/src/i18n/messages/all.ja.json index 22f6544c4e..7660cc2a82 100644 --- a/src/i18n/messages/all.ja.json +++ b/src/i18n/messages/all.ja.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "使用:「{value}」", "errorIconAriaLabel": "エラー", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {「{value}」を使用} true {「{value}」を追加} other {}}", + "i18nStrings.tokenDismissLabel": "{token__label} を削除", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} が挿入されました", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {あと 1 件の項目} other {あと {count} 件の項目}}", "recoveryText": "再試行", "selectedAriaLabel": "選択済み" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "ステップ {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "フォーム送信" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.ko.json b/src/i18n/messages/all.ko.json index 1a7f28262f..cc777eceb7 100644 --- a/src/i18n/messages/all.ko.json +++ b/src/i18n/messages/all.ko.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "사용: ‘{value}’", "errorIconAriaLabel": "오류", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {'{value}' 사용} true {'{value}' 추가} other {}}", + "i18nStrings.tokenDismissLabel": "{token__label} 제거", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} 삽입됨", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1개 항목 더 보기} other {{count}개 항목 더 보기}}", "recoveryText": "재시도", "selectedAriaLabel": "선택됨" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "{stepNumber}단계", "i18nStrings.submitButtonLoadingAnnouncement": "양식 제출 중" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.pt-BR.json b/src/i18n/messages/all.pt-BR.json index 0d557a1097..f37051a94b 100644 --- a/src/i18n/messages/all.pt-BR.json +++ b/src/i18n/messages/all.pt-BR.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Usar: “{value}”", "errorIconAriaLabel": "Erro", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Usar: “{value}”} true {Adicionar \"{value}\"} other {}}", + "i18nStrings.tokenDismissLabel": "Remover {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} inserido", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {Mais 1 item} other {mais {count} itens}}", "recoveryText": "Tentar novamente", "selectedAriaLabel": "Selecionado" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "Etapa {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "Enviando formulário" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.tr.json b/src/i18n/messages/all.tr.json index 52425805bf..a45e43443d 100644 --- a/src/i18n/messages/all.tr.json +++ b/src/i18n/messages/all.tr.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "Şunu kullan: \"{value}\"", "errorIconAriaLabel": "Hata", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {Şunu kullan: \"{value}\"} true {\"{value}\" ekle} other {}}", + "i18nStrings.tokenDismissLabel": "{token__label} öğesini kaldır", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} eklendi", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {1 öge daha} other {{count} öge daha}}", "recoveryText": "Yeniden dene", "selectedAriaLabel": "Seçili" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "{stepNumber}. Adım", "i18nStrings.submitButtonLoadingAnnouncement": "Form gönderiliyor" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.zh-CN.json b/src/i18n/messages/all.zh-CN.json index 94378c2933..d2bcf6e56a 100644 --- a/src/i18n/messages/all.zh-CN.json +++ b/src/i18n/messages/all.zh-CN.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "使用:“{value}”", "errorIconAriaLabel": "错误", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {使用:“{value}”} true {添加“{value}”} other {}}", + "i18nStrings.tokenDismissLabel": "删除 {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} 已插入", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {另外 1 个项目} other {另外 {count} 个项目}}", "recoveryText": "重试", "selectedAriaLabel": "已选定" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "第 {stepNumber} 步", "i18nStrings.submitButtonLoadingAnnouncement": "正在提交表单" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.zh-TW.json b/src/i18n/messages/all.zh-TW.json index c99cce1090..1488630663 100644 --- a/src/i18n/messages/all.zh-TW.json +++ b/src/i18n/messages/all.zh-TW.json @@ -49,6 +49,10 @@ "autosuggest": { "enteredTextLabel": "使用:“{value}”", "errorIconAriaLabel": "錯誤", + "i18nStrings.enteredTextLabel": "{isTokenMode, select, false {使用︰\"{value}\"} true {新增 \"{value}\"} other {}}", + "i18nStrings.tokenDismissLabel": "移除 {token__label}", + "i18nStrings.tokenInsertedAriaLabel": "{token__label} 已插入", + "i18nStrings.tokenOverflowAriaLabel": "{count, select, one {還有 1 個項目} other {還有 {count} 個項目}}", "recoveryText": "重試", "selectedAriaLabel": "已選取" }, @@ -520,4 +524,4 @@ "i18nStrings.stepNumberLabel": "步驟 {stepNumber}", "i18nStrings.submitButtonLoadingAnnouncement": "提交表單" } -} \ No newline at end of file +} diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 1ee2756882..0c02d1afc5 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -51,6 +51,7 @@ export interface InternalInputProps __onEndIconClick?: () => void; __noBorderRadius?: boolean; + __noBorder?: boolean; __onDelayedInput?: NonCancelableEventHandler; __onBlurWithDetail?: NonCancelableEventHandler<{ relatedTarget: Node | null }>; @@ -78,6 +79,7 @@ function InternalInput( disableBrowserAutocorrect, spellcheck, __noBorderRadius, + __noBorder, __startIcon, __startIconVariant = 'subtle', @@ -184,6 +186,7 @@ function InternalInput( __endIcon && styles['input-has-icon-end'], __startIcon && styles['input-has-icon-start'], __noBorderRadius && styles['input-has-no-border-radius'], + __noBorder && styles['input-has-no-border'], !hasPrefixOrSuffix && groupedControlClasses, hasPrefixOrSuffix && styles['input-adorned'], { diff --git a/src/input/styles.scss b/src/input/styles.scss index 9c602fc8b1..aaa28cf936 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -196,6 +196,19 @@ border-end-start-radius: awsui.$border-radius-dropdown; border-end-end-radius: awsui.$border-radius-dropdown; } + &.input-has-no-border { + // Unset min-block-size so the input height is driven by the token trigger + // container's own min-block-size rather than duplicating it here. + min-block-size: unset; + &, + &:focus, + &:hover { + border-block-width: 0; + border-inline-width: 0; + box-shadow: none; + background-color: transparent; + } + } } .grouped { diff --git a/src/internal/components/autosuggest-input/__tests__/autosuggest-input-tokens.test.tsx b/src/internal/components/autosuggest-input/__tests__/autosuggest-input-tokens.test.tsx new file mode 100644 index 0000000000..96c352871b --- /dev/null +++ b/src/internal/components/autosuggest-input/__tests__/autosuggest-input-tokens.test.tsx @@ -0,0 +1,814 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { act, render as renderJsx } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import { AutosuggestProps } from '../../../../../lib/components/autosuggest'; +import AutosuggestInput, { + AutosuggestInputRef, +} from '../../../../../lib/components/internal/components/autosuggest-input'; +import { OverflowDropdown } from '../../../../../lib/components/internal/components/autosuggest-input/overflow-dropdown'; +import AutosuggestInputWrapper from '../../../../../lib/components/test-utils/dom/internal/autosuggest-input'; + +let mockVisibleCount: number | undefined = undefined; +jest.mock('../../../../../lib/components/internal/components/autosuggest-input/use-token-overflow', () => ({ + useTokenOverflow: () => { + const { useRef } = jest.requireActual('react'); + return { containerRef: useRef(null), visibleCount: mockVisibleCount, tokenListMaxWidth: undefined }; + }, +})); + +function makeToken(value: string): AutosuggestProps.Token { + return { value }; +} + +beforeEach(() => { + mockVisibleCount = undefined; +}); + +interface RenderProps { + value?: string; + tokens?: AutosuggestProps.Token[]; + onChange?: (e: { detail: AutosuggestProps.ChangeDetail }) => void; + disabled?: boolean; + readOnly?: boolean; + tokenDismissLabel?: (v: string) => string; + tokenOverflowAriaLabel?: (n: number) => string; + tokenInsertedAriaLabel?: string; +} + +function render({ value = '', tokens = [], onChange = jest.fn(), ...rest }: RenderProps = {}) { + const { container, rerender } = renderJsx( + + ); + return { wrapper: new AutosuggestInputWrapper(container), container, rerender, onChange }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- +describe('token mode — rendering', () => { + test('shows token trigger when tokens prop is provided', () => { + const { wrapper } = render({ tokens: [makeToken('a')] }); + expect(wrapper.findTokenTrigger()).not.toBeNull(); + }); + + test('hides token trigger when tokens prop is absent', () => { + const { container } = renderJsx( {}} />); + expect(new AutosuggestInputWrapper(container).findTokenTrigger()).toBeNull(); + }); + + test('findInlineTokens returns empty array when not in token mode', () => { + const { container } = renderJsx( {}} />); + expect(new AutosuggestInputWrapper(container).findInlineTokens()).toEqual([]); + }); + + test('renders each token label', () => { + const { wrapper } = render({ tokens: [makeToken('us-east-1'), makeToken('eu-west-1')] }); + expect(wrapper.findInlineToken(1)!.getElement()).toHaveTextContent('us-east-1'); + expect(wrapper.findInlineToken(2)!.getElement()).toHaveTextContent('eu-west-1'); + }); + + test('no overflow pill when all tokens are visible', () => { + const { wrapper } = render({ tokens: [makeToken('a'), makeToken('b')] }); + expect(wrapper.findOverflowPill()).toBeNull(); + }); + + test('overflow pill appears when mockVisibleCount hides tokens', () => { + mockVisibleCount = 1; + const { wrapper } = render({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + expect(wrapper.findOverflowPill()).not.toBeNull(); + expect(wrapper.findOverflowPill()!.getElement()).toHaveTextContent('+2'); + }); + + test('overflow pill aria-label uses tokenOverflowAriaLabel i18n prop', () => { + mockVisibleCount = 0; + const { container } = render({ + tokens: [makeToken('a'), makeToken('b'), makeToken('c')], + tokenOverflowAriaLabel: n => `${n} more regions`, + }); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + expect(pill).toHaveAttribute('aria-label', '3 more regions'); + }); + + test('disabled state makes dismiss buttons aria-disabled (not focusable)', () => { + const { container } = render({ tokens: [makeToken('a'), makeToken('b')], disabled: true }); + const buttons = container.querySelectorAll('[data-token-item] button'); + buttons.forEach(btn => expect(btn).toHaveAttribute('aria-disabled', 'true')); + }); + + test('readOnly state renders dismiss buttons as aria-disabled (non-functional)', () => { + const { container } = render({ tokens: [makeToken('a')], readOnly: true }); + const btn = container.querySelector('[data-token-item] button'); + expect(btn).not.toBeNull(); + expect(btn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('token with icon renders icon element inside the token', () => { + const iconNode = ; + const { container } = render({ tokens: [{ value: 'a', icon: iconNode }] }); + expect(container.querySelector('[data-token-item] [class*="icon-inline"]')).not.toBeNull(); + }); + + test('ariaLabel is applied to the group wrapper', () => { + const { container } = renderJsx( + {}} tokens={[makeToken('a')]} ariaLabel="Region filter" /> + ); + expect(container.querySelector('[role="group"]')).toHaveAttribute('aria-label', 'Region filter'); + }); + + test('renders dropdownFooter without dropdownContent in token mode', () => { + const ref = React.createRef(); + const { container } = renderJsx( + {}} + tokens={[makeToken('a')]} + dropdownFooter={
footer
} + /> + ); + act(() => ref.current?.open()); + expect(container.querySelector('[data-testid="footer-only"]')).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Adding tokens +// --------------------------------------------------------------------------- +describe('token mode — adding tokens', () => { + test('Enter on non-empty input fires onChange with new token and clears value', () => { + const { wrapper, onChange } = render({ value: 'ap-east-1', tokens: [] }); + wrapper.findInput().findNativeInput().keydown(KeyCode.enter); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { value: '', tokens: [{ value: 'ap-east-1' }] } }) + ); + }); + + test('Enter on empty input does not fire onChange', () => { + const { wrapper, onChange } = render({ value: '', tokens: [] }); + wrapper.findInput().findNativeInput().keydown(KeyCode.enter); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('Enter on whitespace-only input does not fire onChange', () => { + const { wrapper, onChange } = render({ value: ' ', tokens: [] }); + wrapper.findInput().findNativeInput().keydown(KeyCode.enter); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('token value is trimmed before being added', () => { + const { wrapper, onChange } = render({ value: ' ap-east-1 ', tokens: [] }); + wrapper.findInput().findNativeInput().keydown(KeyCode.enter); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { value: '', tokens: [{ value: 'ap-east-1' }] } }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// Dismissing tokens +// --------------------------------------------------------------------------- +describe('token mode — dismissing tokens', () => { + test('clicking dismiss fires onChange without that token', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('a'), makeToken('b')] }); + act(() => wrapper.findInlineToken(1)!.find('button')!.click()); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: '', tokens: [{ value: 'b' }] } })); + }); + + test('dismissing last token fires onChange with empty array', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('only')] }); + act(() => wrapper.findInlineToken(1)!.find('button')!.click()); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: '', tokens: [] } })); + }); + + test('Backspace on empty input focuses last token dismiss button (does not dismiss)', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('a'), makeToken('b')], value: '' }); + wrapper.findInput().findNativeInput().keydown(KeyCode.backspace); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('Backspace on non-empty input does nothing to tokens', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('a')], value: 'x' }); + wrapper.findInput().findNativeInput().keydown(KeyCode.backspace); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('Backspace on a token span removes that token', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('a'), makeToken('b')] }); + const span = wrapper.findInlineToken(1)!.getElement().closest('[data-token-item]')!; + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.backspace, bubbles: true })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: '', tokens: [{ value: 'b' }] } })); + }); + + test('Delete on a token span removes that token', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('a'), makeToken('b')] }); + const span = wrapper.findInlineToken(2)!.getElement().closest('[data-token-item]')!; + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 46, bubbles: true })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: '', tokens: [{ value: 'a' }] } })); + }); + + test('clicking dismiss button does not fire onChange when disabled', () => { + const { container, onChange } = render({ tokens: [makeToken('a')], disabled: true }); + const btn = container.querySelector('[data-token-item] button')!; + act(() => btn.click()); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('clicking dismiss button does not fire onChange when readOnly', () => { + const { container, onChange } = render({ tokens: [makeToken('a')], readOnly: true }); + const btn = container.querySelector('[data-token-item] button')!; + act(() => btn.click()); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Keyboard navigation +// --------------------------------------------------------------------------- +describe('token mode — keyboard navigation', () => { + test('ArrowLeft on second visible token focuses first token dismiss button', () => { + const { wrapper } = render({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + const span = wrapper.findInlineToken(2)!.getElement().closest('[data-token-item]')!; + const firstBtn = wrapper.findInlineToken(1)!.find('button')!.getElement(); + const spy = jest.spyOn(firstBtn, 'focus'); + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.left, bubbles: true })); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('ArrowRight on last visible token focuses the input', () => { + const { wrapper, container } = render({ tokens: [makeToken('a'), makeToken('b')] }); + const span = wrapper.findInlineToken(2)!.getElement().closest('[data-token-item]')!; + const input = container.querySelector('[role="combobox"]')!; + const spy = jest.spyOn(input, 'focus'); + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.right, bubbles: true })); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('ArrowRight on non-last token focuses next token dismiss button', () => { + const { wrapper } = render({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + const span = wrapper.findInlineToken(1)!.getElement().closest('[data-token-item]')!; + const nextBtn = wrapper.findInlineToken(2)!.find('button')!.getElement(); + const spy = jest.spyOn(nextBtn, 'focus'); + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.right, bubbles: true })); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('ArrowLeft on first visible token does nothing when no overflow pill exists', () => { + const { wrapper, onChange } = render({ tokens: [makeToken('a'), makeToken('b')] }); + const span = wrapper.findInlineToken(1)!.getElement().closest('[data-token-item]')!; + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.left, bubbles: true })); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('ArrowLeft on first visible token focuses overflow pill when hidden tokens exist', () => { + mockVisibleCount = 1; + const { wrapper, container } = render({ tokens: [makeToken('hidden'), makeToken('visible')] }); + const span = wrapper.findInlineToken(1)!.getElement().closest('[data-token-item]')!; + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + const spy = jest.spyOn(pill, 'focus'); + span.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.left, bubbles: true })); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('Backspace on empty input focuses overflow pill when all tokens are hidden', () => { + mockVisibleCount = 0; + const { wrapper, container } = render({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + const spy = jest.spyOn(pill, 'focus'); + wrapper.findInput().findNativeInput().keydown(KeyCode.backspace); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('ArrowDown fires onPressArrowDown when overflow panel is closed', () => { + const onPressArrowDown = jest.fn(); + const { container } = renderJsx( + {}} tokens={[]} onPressArrowDown={onPressArrowDown} /> + ); + new AutosuggestInputWrapper(container).findInput().findNativeInput().keydown(KeyCode.down); + expect(onPressArrowDown).toHaveBeenCalled(); + }); + + test('ArrowDown is blocked when overflow panel is open', () => { + mockVisibleCount = 0; + const onPressArrowDown = jest.fn(); + const { container } = renderJsx( + {}} + tokens={[makeToken('a'), makeToken('b'), makeToken('c')]} + onPressArrowDown={onPressArrowDown} + /> + ); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + const input = container.querySelector('[role="combobox"]')!; + input.dispatchEvent( + new KeyboardEvent('keydown', { keyCode: KeyCode.down, key: 'ArrowDown', bubbles: true, cancelable: true }) + ); + expect(onPressArrowDown).not.toHaveBeenCalled(); + }); + + test('ArrowUp is blocked when overflow panel is open', () => { + mockVisibleCount = 0; + const onPressArrowUp = jest.fn(); + const { container } = renderJsx( + {}} + tokens={[makeToken('a'), makeToken('b'), makeToken('c')]} + onPressArrowUp={onPressArrowUp} + /> + ); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + const input = container.querySelector('[role="combobox"]')!; + input.dispatchEvent( + new KeyboardEvent('keydown', { keyCode: KeyCode.up, key: 'ArrowUp', bubbles: true, cancelable: true }) + ); + expect(onPressArrowUp).not.toHaveBeenCalled(); + }); + + test('Escape clears a non-empty input value', () => { + const { wrapper, onChange } = render({ value: 'hello', tokens: [] }); + wrapper.findInput().findNativeInput().keydown(KeyCode.escape); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: expect.objectContaining({ value: '' }) })); + }); + + test('Enter during IME composition does not add a token', () => { + const { container, onChange } = render({ value: 'hello', tokens: [] }); + const input = container.querySelector('[role="combobox"]')!; + input.dispatchEvent(new Event('compositionstart', { bubbles: true })); + input.dispatchEvent( + new KeyboardEvent('keydown', { keyCode: KeyCode.enter, key: 'Enter', bubbles: true, cancelable: true }) + ); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Overflow pill & panel +// --------------------------------------------------------------------------- +describe('token mode — overflow pill and panel', () => { + function renderWithOverflow(extraTokens = [makeToken('a'), makeToken('b'), makeToken('c')]) { + mockVisibleCount = 0; + const onChange = jest.fn(); + const { container, rerender } = renderJsx(); + return { container, rerender, onChange }; + } + + test('pill click opens the overflow panel', () => { + const { container } = renderWithOverflow(); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + expect(wrapper(container).findOverflowPanel()).not.toBeNull(); + expect(pill).toHaveAttribute('aria-expanded', 'true'); + }); + + test('second pill click closes the overflow panel', () => { + const { container } = renderWithOverflow(); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + act(() => pill.click()); + expect(pill).toHaveAttribute('aria-expanded', 'false'); + }); + + test('Escape key in the overflow panel closes it and returns focus to pill', () => { + const { container } = renderWithOverflow(); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + const spy = jest.spyOn(pill, 'focus'); + const panel = wrapper(container).findOverflowPanel()!.getElement(); + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + expect(pill).toHaveAttribute('aria-expanded', 'false'); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('dismissing a token from the overflow panel fires onChange', () => { + const { container, onChange } = renderWithOverflow(); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + const firstBtn = wrapper(container).findOverflowPanel()!.getElement().querySelector('button')!; + act(() => firstBtn.click()); + expect(onChange).toHaveBeenCalled(); + }); + + test('panel closes automatically when all tokens become visible', () => { + mockVisibleCount = 1; + const tokens = [makeToken('a'), makeToken('b'), makeToken('c')]; + const { container, rerender } = renderJsx( {}} tokens={tokens} />); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + expect(pill).toHaveAttribute('aria-expanded', 'true'); + + mockVisibleCount = undefined; + act(() => rerender( {}} tokens={tokens} />)); + expect(container.querySelector('button[aria-haspopup="dialog"]')).toBeNull(); + }); + + test('overflow panel is disabled (pill not clickable) when component is disabled', () => { + mockVisibleCount = 0; + const { container } = renderJsx( + {}} tokens={[makeToken('a'), makeToken('b')]} disabled={true} /> + ); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + expect(pill).toBeDisabled(); + }); + + test('overflow panel is accessible when component is readOnly (user can inspect hidden tokens)', () => { + mockVisibleCount = 0; + const { container } = renderJsx( + {}} tokens={[makeToken('a'), makeToken('b')]} readOnly={true} /> + ); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + expect(pill).not.toBeDisabled(); + act(() => pill.click()); + expect(pill).toHaveAttribute('aria-expanded', 'true'); + }); + + function wrapper(container: HTMLElement) { + return new AutosuggestInputWrapper(container); + } +}); + +// --------------------------------------------------------------------------- +// Clear button +// --------------------------------------------------------------------------- +describe('token mode — clear button', () => { + test('renders clear button when input has text', () => { + const { container } = render({ value: 'some-text', tokens: [] }); + expect(container.querySelector('[class*="input-icon-end"] button')).not.toBeNull(); + }); + + test('does not render clear button when input is empty', () => { + const { container } = render({ value: '', tokens: [] }); + expect(container.querySelector('[class*="input-icon-end"] button')).toBeNull(); + }); + + test('does not render clear button when disabled', () => { + const { container } = render({ value: 'text', disabled: true }); + expect(container.querySelector('[class*="input-icon-end"] button')).toBeNull(); + }); + + test('does not render clear button when readOnly', () => { + const { container } = render({ value: 'text', readOnly: true }); + expect(container.querySelector('[class*="input-icon-end"] button')).toBeNull(); + }); + + test('clicking clear button fires onChange with empty value', () => { + const { container, onChange } = render({ value: 'text', tokens: [] }); + const btn = container.querySelector('[class*="input-icon-end"] button')!; + act(() => btn.click()); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: expect.objectContaining({ value: '' }) })); + }); +}); + +// --------------------------------------------------------------------------- +// Accessibility +// --------------------------------------------------------------------------- +describe('token mode — accessibility', () => { + test('first visible token dismiss button is in the tab order (tabIndex=0)', () => { + const { wrapper } = render({ tokens: [makeToken('a'), makeToken('b')] }); + expect(wrapper.findInlineToken(1)!.find('button')!.getElement().tabIndex).toBe(0); + }); + + test('subsequent token dismiss buttons are removed from tab order (tabIndex=-1)', () => { + const { wrapper } = render({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + expect(wrapper.findInlineToken(2)!.find('button')!.getElement().tabIndex).toBe(-1); + expect(wrapper.findInlineToken(3)!.find('button')!.getElement().tabIndex).toBe(-1); + }); + + test('visible tokens carry correct aria-setsize and aria-posinset', () => { + const { container } = render({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + const items = Array.from(container.querySelectorAll('[data-token-item]')); + expect(items).toHaveLength(3); + items.forEach((li, i) => { + expect(li.getAttribute('aria-setsize')).toBe('3'); + expect(li.getAttribute('aria-posinset')).toBe(String(i + 1)); + }); + }); + + test('aria-setsize reflects full token count even when some are in overflow', () => { + mockVisibleCount = 1; + const { container } = render({ tokens: [makeToken('hidden'), makeToken('visible')] }); + const items = Array.from(container.querySelectorAll('[data-token-item]')); + expect(items).toHaveLength(1); + expect(items[0].getAttribute('aria-setsize')).toBe('2'); + expect(items[0].getAttribute('aria-posinset')).toBe('2'); + }); + + test('live region is present in the document when in token mode', () => { + render({ tokens: [] }); + expect(document.querySelector('[aria-live="assertive"]')).not.toBeNull(); + }); + + test('live region is absent when not in token mode', () => { + renderJsx( {}} />); + expect(document.querySelector('[aria-live="assertive"]')).toBeNull(); + }); + + test('tokenInsertedAriaLabel prop text is shown in the live region', () => { + jest.useFakeTimers(); + render({ tokens: [], tokenInsertedAriaLabel: 'us-east-1 added' }); + act(() => jest.runAllTimers()); + expect(document.querySelector('[aria-live="assertive"]')?.textContent).toContain('us-east-1 added'); + jest.useRealTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// OverflowDropdown component +// --------------------------------------------------------------------------- +describe('OverflowDropdown', () => { + function renderDropdown({ + tokens = [makeToken('a'), makeToken('b')], + onDismiss = jest.fn(), + onClose = jest.fn(), + open = true, + readOnly = false, + } = {}) { + const triggerRef = React.createRef(); + const { container, rerender } = renderJsx( +
+ + +
+ ); + return { container, rerender, triggerRef, onDismiss, onClose }; + } + + test('renders nothing when open=false', () => { + const { container } = renderDropdown({ open: false }); + expect(container.querySelector('[class*="overflow-panel"]')).toBeNull(); + }); + + test('renders token dismiss buttons when open=true', () => { + const { container } = renderDropdown({ tokens: [makeToken('a'), makeToken('b')] }); + expect(container.querySelectorAll('[class*="overflow-panel"] button')).toHaveLength(2); + }); + + test('onDismiss called with token index when dismiss button is clicked', () => { + const onDismiss = jest.fn(); + const { container } = renderDropdown({ onDismiss }); + const buttons = container.querySelectorAll('[class*="overflow-panel"] button'); + act(() => buttons[1].click()); + expect(onDismiss).toHaveBeenCalledWith(1); + }); + + test('tokens are not dismissible when readOnly', () => { + const onDismiss = jest.fn(); + const { container } = renderDropdown({ onDismiss, readOnly: true }); + const buttons = container.querySelectorAll('[class*="overflow-panel"] button'); + act(() => buttons[0].click()); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + test('Escape key calls onClose', () => { + const onClose = jest.fn(); + const { container } = renderDropdown({ onClose }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + expect(onClose).toHaveBeenCalled(); + }); + + test('Tab key calls onClose', () => { + const onClose = jest.fn(); + const { container } = renderDropdown({ onClose }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + expect(onClose).toHaveBeenCalled(); + }); + + test('ArrowDown moves focus to next button', () => { + const { container } = renderDropdown({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + const buttons = panel.querySelectorAll('button'); + act(() => buttons[0].focus()); + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(buttons[1]); + }); + + test('ArrowDown wraps from last to first button', () => { + const { container } = renderDropdown({ tokens: [makeToken('a'), makeToken('b')] }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + const buttons = panel.querySelectorAll('button'); + act(() => buttons[buttons.length - 1].focus()); + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(buttons[0]); + }); + + test('ArrowUp moves focus to previous button', () => { + const { container } = renderDropdown({ tokens: [makeToken('a'), makeToken('b'), makeToken('c')] }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + const buttons = panel.querySelectorAll('button'); + act(() => buttons[1].focus()); + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(buttons[0]); + }); + + test('ArrowUp wraps from first to last button', () => { + const { container } = renderDropdown({ tokens: [makeToken('a'), makeToken('b')] }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + const buttons = panel.querySelectorAll('button'); + act(() => buttons[0].focus()); + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(buttons[buttons.length - 1]); + }); + + test('outside click closes the panel', () => { + const onClose = jest.fn(); + renderDropdown({ onClose }); + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(onClose).toHaveBeenCalled(); + }); + + test('click inside panel does not close the panel', () => { + const onClose = jest.fn(); + const { container } = renderDropdown({ onClose }); + const panel = container.querySelector('[class*="overflow-panel"]')!; + panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(onClose).not.toHaveBeenCalled(); + }); + + test('dismissing a token moves focus to the next token in the panel', () => { + const onDismiss = jest.fn(); + const { container, rerender, triggerRef } = renderDropdown({ + tokens: [makeToken('a'), makeToken('b'), makeToken('c')], + onDismiss, + }); + const buttons = container.querySelectorAll('[class*="overflow-panel"] button'); + act(() => buttons[0].click()); + // Rerender with token[0] removed — panel still has 2 tokens + rerender( +
+ + +
+ ); + const updatedButtons = container.querySelectorAll('[class*="overflow-panel"] button'); + expect(document.activeElement).toBe(updatedButtons[0]); + }); + + test('dismissing the last token in the panel does not throw', () => { + const onDismiss = jest.fn(); + const { container, rerender, triggerRef } = renderDropdown({ + tokens: [makeToken('only')], + onDismiss, + }); + const buttons = container.querySelectorAll('[class*="overflow-panel"] button'); + act(() => buttons[0].click()); + // Rerender with empty tokens — pendingFocusAfterDismissRef fires but tokens.length === 0 + expect(() => { + rerender( +
+ + +
+ ); + }).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Focus management after overflow interactions +// --------------------------------------------------------------------------- +describe('token mode — focus management', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('dismissing last token focuses the input', () => { + const { wrapper, container, rerender, onChange } = render({ tokens: [makeToken('a'), makeToken('b')] }); + act(() => wrapper.findInlineToken(1)!.find('button')!.click()); + rerender(); + const input = container.querySelector('[role="combobox"]')!; + const focusSpy = jest.spyOn(input, 'focus'); + act(() => { + rerender(); + }); + act(() => jest.runAllTimers()); + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); + }); + + test('dismissing the only overflow token when visible tokens remain focuses first visible token', () => { + mockVisibleCount = 2; + const onChange = jest.fn(); + const tokens = [makeToken('a'), makeToken('b'), makeToken('c')]; + const { container, rerender } = renderJsx( + `Remove ${v}`} /> + ); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + const panel = new AutosuggestInputWrapper(container).findOverflowPanel()!; + const dismissBtn = panel.find('button[aria-label]')!.getElement() as HTMLButtonElement; + act(() => dismissBtn.click()); + // Simulate consumer removing the dismissed token; all remaining tokens now fit + mockVisibleCount = undefined; + const remainingTokens = [makeToken('b'), makeToken('c')]; + act(() => { + rerender( + `Remove ${v}`} + /> + ); + }); + act(() => jest.runAllTimers()); + const firstVisibleDismiss = container.querySelector('[data-token-item] button'); + expect(document.activeElement).toBe(firstVisibleDismiss); + }); + + test('overflowOpen resets to false when hidden tokens become empty', () => { + mockVisibleCount = 1; + const tokens = [makeToken('a'), makeToken('b'), makeToken('c')]; + const { container, rerender } = renderJsx( {}} tokens={tokens} />); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + expect(pill).toHaveAttribute('aria-expanded', 'true'); + mockVisibleCount = undefined; + act(() => rerender( {}} tokens={tokens} />)); + expect(container.querySelector('button[aria-haspopup="dialog"]')).toBeNull(); + }); + + test('ArrowRight on overflow pill focuses first visible token dismiss button', () => { + mockVisibleCount = 1; + const { wrapper, container } = render({ tokens: [makeToken('hidden'), makeToken('visible')] }); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + const firstToken = wrapper.findInlineToken(1)!.find('button')!.getElement(); + const spy = jest.spyOn(firstToken, 'focus'); + pill.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 39 /* ArrowRight */, bubbles: true })); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test('Backspace on empty input when all tokens are hidden focuses the overflow pill', () => { + mockVisibleCount = 0; + const { wrapper, container } = render({ tokens: [makeToken('a'), makeToken('b')] }); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + const spy = jest.spyOn(pill, 'focus'); + wrapper.findInput().findNativeInput().keydown(39 /* just ensure Backspace path */); + wrapper.findInput().findNativeInput().keydown(8 /* KeyCode.backspace */); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// i18n: tokenDismissLabel +// --------------------------------------------------------------------------- +describe('token mode — tokenDismissLabel i18n', () => { + test('tokenDismissLabel prop sets the dismiss button aria-label for visible tokens', () => { + const { container } = renderJsx( + {}} + tokens={[makeToken('us-east-1')]} + tokenDismissLabel={(v: string) => `Remove ${v}`} + /> + ); + const btn = container.querySelector('[data-token-item] button')!; + expect(btn).toHaveAttribute('aria-label', 'Remove us-east-1'); + }); + + test('tokenDismissLabel prop sets the aria-label in the overflow panel', () => { + mockVisibleCount = 0; + const { container } = renderJsx( + {}} + tokens={[makeToken('eu-west-1')]} + tokenDismissLabel={(v: string) => `Remove ${v}`} + /> + ); + const pill = container.querySelector('button[aria-haspopup="dialog"]')!; + act(() => pill.click()); + const panelBtn = new AutosuggestInputWrapper(container) + .findOverflowPanel()! + .getElement() + .querySelector('button')!; + expect(panelBtn).toHaveAttribute('aria-label', 'Remove eu-west-1'); + }); +}); 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 new file mode 100644 index 0000000000..114142b330 --- /dev/null +++ b/src/internal/components/autosuggest-input/__tests__/use-token-overflow.test.tsx @@ -0,0 +1,271 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { useTokenOverflow } from '../../../../../lib/components/internal/components/autosuggest-input/use-token-overflow'; + +// --------------------------------------------------------------------------- +// ResizeObserver mock that fires the callback synchronously on observe() +// so the useLayoutEffect inside useTokenOverflow actually executes. +// --------------------------------------------------------------------------- + +class FiringResizeObserver { + private cb: (entries: ResizeObserverEntry[]) => void; + constructor(cb: (entries: ResizeObserverEntry[]) => void) { + this.cb = cb; + } + observe(el: Element) { + const width = (el as HTMLElement).offsetWidth; + const height = (el as HTMLElement).offsetHeight; + const sizeEntry = { inlineSize: width, blockSize: height }; + this.cb([ + { + target: el, + borderBoxSize: [sizeEntry], + contentBoxSize: [sizeEntry], + devicePixelContentBoxSize: [sizeEntry], + contentRect: el.getBoundingClientRect(), + } as unknown as ResizeObserverEntry, + ]); + } + unobserve() {} + disconnect() {} +} + +// --------------------------------------------------------------------------- +// Width + getComputedStyle mocks +// --------------------------------------------------------------------------- + +function setupMocks({ + containerWidth, + tokenWidth, + pillWidth, + iconWidth, + padding = 0, + gap = 0, +}: { + containerWidth: number; + tokenWidth: number; + pillWidth: number; + iconWidth: number; + padding?: number; + gap?: number; +}) { + const origWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!; + const origGetComputedStyle = window.getComputedStyle; + const origResizeObserver = window.ResizeObserver; + + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get() { + if ((this as HTMLElement).hasAttribute('data-measure-pill')) { + return pillWidth; + } + if ((this as HTMLElement).hasAttribute('data-measure-token')) { + return tokenWidth; + } + if ((this as HTMLElement).hasAttribute('data-search-icon')) { + return iconWidth; + } + return containerWidth; + }, + }); + + window.getComputedStyle = (elt: Element) => { + const orig = origGetComputedStyle.call(window, elt); + return new Proxy(orig, { + get(target: CSSStyleDeclaration, prop: string | symbol) { + if (prop === 'paddingInlineStart') { + return `${padding}px`; + } + if (prop === 'paddingInlineEnd') { + return `${padding}px`; + } + if (prop === 'gap') { + return `${gap}px`; + } + if (prop === 'columnGap') { + return `${gap}px`; + } + const val = (target as any)[prop]; + return typeof val === 'function' ? val.bind(target) : val; + }, + }); + }; + + // Replace the global ResizeObserver with our firing version + (window as any).ResizeObserver = FiringResizeObserver; + + return () => { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', origWidth); + window.getComputedStyle = origGetComputedStyle; + (window as any).ResizeObserver = origResizeObserver; + }; +} + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +interface HarnessProps { + tokenCount: number; + enabled: boolean; + onResult: (r: { visibleCount: number | undefined; tokenListMaxWidth: number | undefined }) => void; +} + +function Harness({ tokenCount, enabled, onResult }: HarnessProps) { + const measureRef = React.useRef(null); + const triggerRowRef = React.useRef(null); + const { containerRef, visibleCount, tokenListMaxWidth } = useTokenOverflow( + measureRef, + triggerRowRef, + tokenCount, + `key-${tokenCount}`, + enabled + ); + + React.useEffect(() => { + onResult({ visibleCount, tokenListMaxWidth }); + }); + + const tokens = Array.from({ length: tokenCount }, (_, i) => ( + + t{i} + + )); + + return ( +
+
+ i +
+ +N + {tokens} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useTokenOverflow — disabled', () => { + test('returns undefined visibleCount when enabled=false', () => { + const results: Array<{ visibleCount: number | undefined }> = []; + const restore = setupMocks({ containerWidth: 400, tokenWidth: 80, pillWidth: 40, iconWidth: 24 }); + render( results.push(r)} />); + restore(); + expect(results[results.length - 1].visibleCount).toBeUndefined(); + }); +}); + +describe('useTokenOverflow — ResizeObserver callback triggers remeasurement', () => { + test('borderBoxWidth > 0 triggers containerResizeCount increment and re-measurement', () => { + const results: Array<{ visibleCount: number | undefined }> = []; + // container=500px, 2 small tokens → all fit + const restore = setupMocks({ + containerWidth: 500, + tokenWidth: 60, + pillWidth: 40, + iconWidth: 24, + padding: 0, + gap: 0, + }); + const { unmount } = render( results.push(r)} />); + restore(); + unmount(); + // After FiringResizeObserver fires the callback, useLayoutEffect runs and sets visibleCount + const last = results[results.length - 1]; + expect(last.visibleCount).toBeDefined(); + }); +}); + +describe('useTokenOverflow — sets visibleCount=0 when no tokens to measure', () => { + test('sets visibleCount=0 when no measure-token elements exist', () => { + const results: Array<{ visibleCount: number | undefined }> = []; + const restore = setupMocks({ + containerWidth: 400, + tokenWidth: 80, + pillWidth: 40, + iconWidth: 0, + padding: 0, + gap: 0, + }); + // tokenCount=0 → no [data-measure-token] elements → early return path sets visibleCount=0 + const { unmount } = render( results.push(r)} />); + restore(); + unmount(); + const last = results[results.length - 1]; + if (last.visibleCount !== undefined) { + expect(last.visibleCount).toBe(0); + } + }); +}); + +describe('useTokenOverflow — all tokens fit without overflow pill', () => { + test('sets visibleCount=tokenCount when all tokens fit without pill', () => { + const results: Array<{ visibleCount: number | undefined; tokenListMaxWidth: number | undefined }> = []; + // 2 tokens × 60px = 120; container=500, iconWidth=24, inputMinWidth=100, budget=376 → 120 fits + const restore = setupMocks({ + containerWidth: 500, + tokenWidth: 60, + pillWidth: 40, + iconWidth: 24, + padding: 0, + gap: 0, + }); + const { unmount } = render( results.push(r)} />); + restore(); + unmount(); + const last = results[results.length - 1]; + if (last.visibleCount !== undefined) { + expect(last.visibleCount).toBe(2); + expect(last.tokenListMaxWidth).toBeUndefined(); + } + }); +}); + +describe("useTokenOverflow — overflow: fewer tokens shown when they don't fit", () => { + test('sets visibleCount < tokenCount when tokens overflow', () => { + const results: Array<{ visibleCount: number | undefined }> = []; + // 3 tokens × 300px each; container=200 → all overflow; first token cost=300 > budgetWithPill + const restore = setupMocks({ + containerWidth: 200, + tokenWidth: 300, + pillWidth: 40, + iconWidth: 0, + padding: 0, + gap: 0, + }); + const { unmount } = render( results.push(r)} />); + restore(); + unmount(); + const last = results[results.length - 1]; + if (last.visibleCount !== undefined) { + expect(last.visibleCount).toBeLessThan(3); + } + }); + + test('sets visibleCount=0 and computes newMaxWidth when no tokens fit', () => { + const results: Array<{ visibleCount: number | undefined; tokenListMaxWidth: number | undefined }> = []; + // container=50, tokenWidth=500 → budgetWithPill will be negative → 0 tokens fit + const restore = setupMocks({ + containerWidth: 50, + tokenWidth: 500, + pillWidth: 30, + iconWidth: 0, + padding: 0, + gap: 0, + }); + const { unmount } = render( results.push(r)} />); + restore(); + unmount(); + const last = results[results.length - 1]; + if (last.visibleCount !== undefined) { + expect(last.visibleCount).toBe(0); + } + }); +}); diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 54aebf840e..15610b33fd 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -1,14 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { Ref, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import React, { Ref, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; import { AutosuggestProps } from '../../../autosuggest/interfaces'; import { ExpandToViewport } from '../../../dropdown/interfaces'; import InternalDropdown from '../../../dropdown/internal'; +import InternalIcon from '../../../icon/internal'; import { BaseChangeDetail, BaseInputProps, @@ -18,22 +19,29 @@ import { InputProps, } from '../../../input/interfaces'; import InternalInput from '../../../input/internal'; +import InternalLiveRegion from '../../../live-region/internal'; +import InternalToken from '../../../token/internal'; import { BaseComponentProps } from '../../../types/base-component'; import { BaseKeyDetail, NonCancelableEventHandler } from '../../../types/events'; import { FormFieldValidationControlProps } from '../../../types/form-field'; import { getBaseProps } from '../../base-component'; import { getBreakpointValue } from '../../breakpoints'; -import { ResetGroupedControlContext } from '../../context/control-group-context'; +import { ResetGroupedControlContext, useGroupedControlContext } from '../../context/control-group-context'; import { useFormFieldContext } from '../../context/form-field-context'; import { fireCancelableEvent, fireNonCancelableEvent } from '../../events'; import { InternalBaseComponentProps } from '../../hooks/use-base-component'; import { useIMEComposition } from '../../hooks/use-ime-composition'; -import { KeyCode } from '../../keycode'; +import { KeyCode, KeyCodeDelete } from '../../keycode'; import { getDropdownMinWidth } from '../../utils/get-dropdown-min-width'; import { nodeBelongs } from '../../utils/node-belongs'; import { processAttributes } from '../../utils/with-native-attributes'; +import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; +import { OverflowDropdown } from './overflow-dropdown'; +import { useTokenListFocus } from './use-token-list-focus'; +import { useTokenOverflow } from './use-token-overflow'; import styles from './styles.css.js'; +import testUtilStyles from './test-classes/styles.css.js'; interface AutosuggestInputProps extends BaseComponentProps, @@ -59,6 +67,10 @@ interface AutosuggestInputProps onPressArrowUp?: () => void; onPressEnter?: () => boolean; style?: InputProps['style']; + tokens?: ReadonlyArray; + tokenOverflowAriaLabel?: (hiddenCount: number) => string; + tokenInsertedAriaLabel?: string; + tokenDismissLabel?: (value: string) => string; } interface AutosuggestInputFocusOptions { @@ -106,29 +118,67 @@ const AutosuggestInput = React.forwardRef( onPressArrowUp, onPressEnter, style, + tokens, + tokenOverflowAriaLabel, + tokenInsertedAriaLabel, + tokenDismissLabel, __internalRootRef, ...restProps }: AutosuggestInputProps, ref: Ref ) => { + const isTokenMode = !!tokens; + const tokenList = tokens ?? []; + const baseProps = getBaseProps(restProps); const formFieldContext = useFormFieldContext(restProps); + const { invalid, warning, ariaLabelledby } = formFieldContext; + const groupedControlProps = useGroupedControlContext(); const inputRef = useRef(null); + const triggerRowRef = useRef(null); + const tokenListRef = useRef(null); + const measureRef = useRef(null); + const overflowPillRef = useRef(null); const dropdownContentRef = useRef(null); const dropdownFooterRef = useRef(null); const preventOpenOnFocusRef = useRef(false); const preventCloseOnBlurRef = useRef(false); + const [triggerWidth, setTriggerWidth] = useState(null); + const [tokensOverflowOpen, setTokensOverflowOpen] = useState(false); + const [open, setOpen] = useState(false); + const [focusFirstVisibleAfterOverflow, setFocusFirstVisibleAfterOverflow] = useState(false); + + const tokenContentKey = tokenList.map(t => `${t.value}::${t.icon ?? ''}`).join('|'); + + const { containerRef, visibleCount, tokenListMaxWidth } = useTokenOverflow( + measureRef, + triggerRowRef, + tokenList.length, + tokenContentKey, + isTokenMode + ); + useResizeObserver( - () => inputRef.current, + () => (isTokenMode ? triggerRowRef.current : inputRef.current), entry => entry.borderBoxWidth > 0 && setTriggerWidth(entry.borderBoxWidth) ); - const [open, setOpen] = useState(false); + // No dependency array: runs after every render to keep tabIndex in sync with the + // current DOM state. A deps array would cause a frame of stale tabIndex values + // after token additions/removals before the next re-render syncs it. + useLayoutEffect(() => { + if (!isTokenMode || !tokenListRef.current) { + return; + } + const dismissButtons = tokenListRef.current.querySelectorAll('[data-token-item] button'); + dismissButtons.forEach((btn, idx) => { + btn.tabIndex = !disabled && idx === 0 && hiddenTokens.length === 0 ? 0 : -1; + }); + }); const openDropdown = () => !readOnly && setOpen(true); - const closeDropdown = () => { setOpen(false); fireNonCancelableEvent(onCloseDropdown, null); @@ -150,6 +200,67 @@ const AutosuggestInput = React.forwardRef( close: closeDropdown, })); + const addToken = (label: string) => { + if (!label.trim()) { + return; + } + const updated = [...tokenList, { value: label.trim() }]; + fireNonCancelableEvent(onChange, { value: '', tokens: updated }); + }; + + const removeToken = (index: number, suppressFocus = false) => { + preventOpenOnFocusRef.current = true; + const updated = tokenList.filter((_, i) => i !== index); + fireNonCancelableEvent(onChange, { value, tokens: updated }); + if (suppressFocus) { + return; + } + if (updated.length === 0) { + inputRef.current?.focus(); + } else { + const preRemovalVisibleStart = tokenList.length - effectiveVisible; + const targetSlot = index - preRemovalVisibleStart; + requestAnimationFrame(() => { + const dismissButtons = tokenListRef.current?.querySelectorAll('[data-token-item] button'); + if (!dismissButtons || dismissButtons.length === 0) { + inputRef.current?.focus(); + return; + } + const positionInVisible = Math.max(0, Math.min(targetSlot, dismissButtons.length - 1)); + dismissButtons[positionInVisible].focus(); + }); + } + }; + + const effectiveVisible = visibleCount === undefined ? tokenList.length : visibleCount; + const visibleTokens = tokenList.slice(tokenList.length - effectiveVisible); + const hiddenTokens = tokenList.slice(0, tokenList.length - effectiveVisible); + + useEffect(() => { + if (focusFirstVisibleAfterOverflow && hiddenTokens.length === 0) { + setFocusFirstVisibleAfterOverflow(false); + const buttons = tokenListRef.current?.querySelectorAll('[data-token-item] button'); + if (buttons && buttons.length > 0) { + buttons[0].focus(); + } else { + inputRef.current?.focus(); + } + } + }, [focusFirstVisibleAfterOverflow, hiddenTokens.length]); + + useEffect(() => { + if (hiddenTokens.length === 0 && tokensOverflowOpen) { + setTokensOverflowOpen(false); + } + }, [hiddenTokens.length, tokensOverflowOpen]); + + const { focusTokenAtIndex } = useTokenListFocus({ + tokenListRef, + inputRef, + tokenListLength: tokenList.length, + effectiveVisible, + }); + const handleBlur = () => { if (!preventCloseOnBlurRef.current) { closeDropdown(); @@ -158,7 +269,7 @@ const AutosuggestInput = React.forwardRef( }; const handleFocus = () => { - if (!preventOpenOnFocusRef.current) { + if (!preventOpenOnFocusRef.current && !tokensOverflowOpen) { openDropdown(); fireNonCancelableEvent(onFocus, null); } @@ -177,19 +288,25 @@ const AutosuggestInput = React.forwardRef( inputRef.current?.focus(); } } else if (value) { - fireNonCancelableEvent(onChange, { value: '' }); + fireNonCancelableEvent(onChange, isTokenMode ? { value: '', tokens: tokenList } : { value: '' }); } }; const handleKeyDown = (event: CustomEvent) => { switch (event.detail.keyCode) { case KeyCode.down: { + if (tokensOverflowOpen) { + break; + } onPressArrowDown?.(); openDropdown(); event.preventDefault(); break; } case KeyCode.up: { + if (tokensOverflowOpen) { + break; + } onPressArrowUp?.(); openDropdown(); event.preventDefault(); @@ -200,12 +317,26 @@ const AutosuggestInput = React.forwardRef( event.preventDefault(); return; } - if (open) { if (!onPressEnter?.()) { closeDropdown(); } event.preventDefault(); + } else if (isTokenMode && value) { + addToken(value); + event.preventDefault(); + } + fireKeydown(event); + break; + } + case KeyCode.backspace: { + if (!readOnly && !disabled && isTokenMode && value === '' && tokenList.length > 0) { + if (effectiveVisible === 0) { + overflowPillRef.current?.focus(); + } else { + focusTokenAtIndex(tokenList.length - 1); + } + event.preventDefault(); } fireKeydown(event); break; @@ -226,13 +357,13 @@ const AutosuggestInput = React.forwardRef( } }; - const handleChange = (value: string) => { + const handleChange = (val: string) => { openDropdown(); - fireNonCancelableEvent(onChange, { value }); + fireNonCancelableEvent(onChange, isTokenMode ? { value: val, tokens: tokenList } : { value: val }); }; - const handleDelayedInput = (value: string) => { - fireNonCancelableEvent(onDelayedInput, { value }); + const handleDelayedInput = (val: string) => { + fireNonCancelableEvent(onDelayedInput, { value: val }); }; const handleDropdownMouseDown: React.MouseEventHandler = event => { @@ -249,45 +380,24 @@ const AutosuggestInput = React.forwardRef( } }; - const expanded = open && dropdownExpanded; - const nativeAttributes: BaseInputProps['nativeInputAttributes'] = { - name, - placeholder, - autoFocus, - onClick: openDropdown, - role: 'combobox', - 'aria-autocomplete': 'list', - 'aria-expanded': expanded, - 'aria-controls': open ? ariaControls : undefined, - // 'aria-owns' needed for safari+vo to announce activedescendant content - 'aria-owns': open ? ariaControls : undefined, - 'aria-label': ariaLabel, - 'aria-activedescendant': ariaActivedescendant, - }; - // Closes dropdown when outside click is detected. // Similar to the internal dropdown implementation but includes the target as well. useEffect(() => { if (!open) { return; } - const clickListener = (event: MouseEvent) => { if ( !nodeBelongs(inputRef.current, event.target) && !nodeBelongs(dropdownContentRef.current, event.target) && - !nodeBelongs(dropdownFooterRef.current, event.target) + !nodeBelongs(dropdownFooterRef.current, event.target) && + !nodeBelongs(triggerRowRef.current, event.target) ) { closeDropdown(); } }; - window.addEventListener('mousedown', clickListener); - - return () => { - window.removeEventListener('mousedown', clickListener); - }; - + return () => window.removeEventListener('mousedown', clickListener); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -295,7 +405,6 @@ const AutosuggestInput = React.forwardRef( // Handle ESC key from focusable elements inside the dropdown (e.g., recovery button) // but NOT from the input itself (that's handled by handleKeyDown) const isFromInput = event.target === inputRef.current || nodeBelongs(inputRef.current, event.target); - if (event.key === 'Escape' && !isFromInput) { event.stopPropagation(); event.preventDefault(); @@ -304,15 +413,70 @@ const AutosuggestInput = React.forwardRef( } }; - return ( -
- ( + + {wrapContent ? ( + + {open && dropdownFooter ? dropdownFooter : null} + + ) : open && dropdownFooter ? ( + dropdownFooter + ) : null} +
+ ) + } + expandToViewport={expandToViewport} + loopFocus={loopFocus} + content={ + open && dropdownContent ? ( +
+ {wrapContent ? ( + {dropdownContent} + ) : ( + dropdownContent + )} +
+ ) : null + } + /> + ); + + if (!isTokenMode) { + return ( +
+ {renderDropdownShell( - } - onMouseDown={handleDropdownMouseDown} - open={open && (!!dropdownContent || !!dropdownFooter)} - footer={ - dropdownFooterRef && ( -
- - {open && dropdownFooter ? dropdownFooter : null} - -
- ) - } - expandToViewport={expandToViewport} - loopFocus={loopFocus} - content={ - open && dropdownContent ? ( -
- {dropdownContent} + />, + true + )} +
+ ); + } + + return ( +
+ +
+ {tokenList.map((token, i) => ( + + undefined} /> + + ))} + +
+
+ {renderDropdownShell( +
+ + + + + {tokenList.length > 0 && ( +
    + {hiddenTokens.length > 0 && ( +
  • + +
  • + )} + + {visibleTokens.map((token, i) => { + const realIndex = tokenList.length - effectiveVisible + i; + return ( +
  • { + const comingFromOutside = + !e.relatedTarget || !tokenListRef.current?.contains(e.relatedTarget as Node); + if (comingFromOutside) { + preventOpenOnFocusRef.current = true; + } + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.keyCode === KeyCode.backspace || e.keyCode === KeyCodeDelete) { + e.preventDefault(); + if (!readOnly && !disabled) { + removeToken(realIndex); + } + } else if (e.keyCode === KeyCode.left) { + e.preventDefault(); + if (i === 0) { + if (hiddenTokens.length > 0) { + overflowPillRef.current?.focus(); + } + } else { + focusTokenAtIndex(realIndex - 1); + } + } else if (e.keyCode === KeyCode.right) { + e.preventDefault(); + if (realIndex < tokenList.length - 1) { + focusTokenAtIndex(realIndex + 1); + } else { + inputRef.current?.focus(); + } + } + }} + > + removeToken(realIndex)} + /> +
  • + ); + })} +
+ )} + +
+ 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} + autoComplete={false} + nativeInputAttributes={processAttributes(nativeAttributes, nativeInputAttributes, 'Autosuggest')} + __skipNativeAttributesWarnings={Object.keys(nativeAttributes)} + __noBorder={true} + {...(!disabled && !readOnly && value + ? { + __endIcon: 'close' as const, + __onEndIconClick: () => { + inputRef.current?.focus(); + fireNonCancelableEvent(onChange, { value: '', tokens: tokenList }); + }, + } + : {})} + {...{ ...formFieldContext, invalid: false, warning: false }} + />
- ) : null - } - /> +
+ )} + {hiddenTokens.length > 0 && ( + { + if (hiddenTokens.length === 1) { + setTokensOverflowOpen(false); + if (tokenList.length === 1) { + inputRef.current?.focus(); + } else { + setFocusFirstVisibleAfterOverflow(true); + } + } + removeToken(i, true); + }} + onClose={() => { + setTokensOverflowOpen(false); + overflowPillRef.current?.focus(); + }} + /> + )} +
); } diff --git a/src/internal/components/autosuggest-input/overflow-dropdown.tsx b/src/internal/components/autosuggest-input/overflow-dropdown.tsx new file mode 100644 index 0000000000..41f82c7177 --- /dev/null +++ b/src/internal/components/autosuggest-input/overflow-dropdown.tsx @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useEffect, useRef } from 'react'; +import clsx from 'clsx'; + +import { AutosuggestProps } from '../../../autosuggest/interfaces'; +import InternalToken from '../../../token/internal'; + +import styles from './styles.css.js'; +import testUtilStyles from './test-classes/styles.selectors.js'; + +export interface OverflowDropdownProps { + tokens: ReadonlyArray; + triggerRef: React.RefObject; + open: boolean; + onDismiss: (index: number) => void; + onClose: () => void; + tokenDismissLabel?: (value: string) => string; + readOnly?: boolean; +} + +export function OverflowDropdown({ + tokens, + triggerRef, + open, + onDismiss, + onClose, + tokenDismissLabel, + readOnly, +}: OverflowDropdownProps) { + const pendingFocusAfterDismissRef = useRef(null); + const listRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + const first = listRef.current?.querySelector('button'); + first?.focus(); + }, [open]); + + // No deps: runs after every render to handle focus restoration after a token dismiss. + useEffect(() => { + if (pendingFocusAfterDismissRef.current === null) { + return; + } + const dismissedIndex = pendingFocusAfterDismissRef.current; + pendingFocusAfterDismissRef.current = null; + if (tokens.length === 0) { + return; + } + const nextPosition = Math.min(dismissedIndex, tokens.length - 1); + const buttons = listRef.current?.querySelectorAll('button'); + if (buttons && buttons[nextPosition]) { + buttons[nextPosition].focus(); + } + }); + + const handlePanelKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + triggerRef.current?.focus(); + } else if (e.key === 'Tab') { + onClose(); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + const buttons = Array.from(listRef.current?.querySelectorAll('button') ?? []); + if (buttons.length === 0) { + return; + } + const focused = buttons.indexOf(document.activeElement as HTMLButtonElement); + if (e.key === 'ArrowUp') { + const prev = focused > 0 ? focused - 1 : buttons.length - 1; + buttons[prev].focus(); + } else { + const next = focused < buttons.length - 1 ? focused + 1 : 0; + buttons[next].focus(); + } + } + }; + + useEffect(() => { + if (!open) { + return; + } + const handleClick = (e: MouseEvent) => { + if ( + panelRef.current && + !panelRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open, onClose, triggerRef]); + + if (!open) { + return null; + } + + return ( +
e.preventDefault()} + onKeyDown={handlePanelKeyDown} + className={clsx(styles['overflow-panel'], testUtilStyles['overflow-panel'])} + > +
    + {tokens.map((token, i) => ( +
  • + { + pendingFocusAfterDismissRef.current = i; + onDismiss(i); + }} + /> +
  • + ))} +
+
+ ); +} diff --git a/src/internal/components/autosuggest-input/styles.scss b/src/internal/components/autosuggest-input/styles.scss index d8e3587abc..174dac7f9c 100644 --- a/src/internal/components/autosuggest-input/styles.scss +++ b/src/internal/components/autosuggest-input/styles.scss @@ -3,6 +3,11 @@ SPDX-License-Identifier: Apache-2.0 */ +@use '../../../internal/styles/tokens' as awsui; +@use '../../../internal/styles' as styles; +@use '../../../internal/styles/foundation' as foundation; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; + .root { // Used in test-utils. } @@ -15,3 +20,172 @@ .dropdown-content { display: contents; } + +.token-trigger { + display: flex; + align-items: center; + flex-wrap: nowrap; + overflow: visible; + 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; + gap: awsui.$space-xxs; + + background-color: awsui.$color-background-input-default; + border-block: awsui.$border-width-field solid awsui.$color-border-input-default; + border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; + border-start-start-radius: styles.$control-border-radius; + border-start-end-radius: styles.$control-border-radius; + border-end-start-radius: styles.$control-border-radius; + border-end-end-radius: styles.$control-border-radius; + + &:focus-within { + @include styles.form-focus-element(); + } + + &.token-trigger-invalid { + @include styles.form-invalid-control(); + &:focus-within { + box-shadow: foundation.$box-shadow-focused-light-invalid; + } + } + + &.token-trigger-warning { + @include styles.form-warning-control(); + &:focus-within { + box-shadow: foundation.$box-shadow-focused-light-invalid; + } + } + + &.token-trigger-disabled { + @include styles.form-disabled-element(); + &:focus-within { + box-shadow: none; + } + } + + &.token-trigger-readonly { + @include styles.form-readonly-element(); + } + + &.grouped { + @include styles.grouped-control; + } +} + +.token-list { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: awsui.$space-xxs; + flex-shrink: 1; + min-inline-size: 0; + overflow: visible; + list-style: none; + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; + + > .token-list-item { + display: contents; + } +} + +.token-input-wrapper { + margin-inline-start: calc(-1 * #{awsui.$space-xxs}); +} + +.search-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.pill-icon { + display: flex; + align-items: center; + @include styles.with-motion { + transition: transform awsui.$motion-duration-rotate-180 awsui.$motion-easing-rotate-180; + } +} + +.pill-icon-open { + transform: rotate(-180deg); +} + +.token-trigger-container { + position: relative; + inline-size: 100%; +} + +.measurement-container { + @include styles.measurement-ghost; + display: flex; + flex-wrap: nowrap; + gap: awsui.$space-xxs; +} + +.overflow-panel { + position: absolute; + inset-block-start: calc(100% - #{awsui.$border-divider-list-width}); + inset-inline-start: 0; + inline-size: max-content; + max-inline-size: min(320px, 90vw); + z-index: 2000; + background-color: awsui.$color-background-dropdown-item-default; + + @include styles.dropdown-shadow(); +} + +.overflow-panel-list { + list-style: none; + padding-block: awsui.$space-xxs; + padding-inline: 0; + margin-block: 0; + margin-inline: 0; + display: flex; + flex-direction: column; +} + +.overflow-panel-item { + display: flex; + align-items: center; + padding-block: awsui.$space-xxs; + padding-inline: awsui.$space-xs; +} + +.token-overflow-pill { + @include styles.styles-reset; + + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding-block: 1px; + padding-inline: awsui.$space-xs; + border-block: awsui.$border-width-field solid awsui.$color-border-control-default; + border-inline: awsui.$border-width-field solid awsui.$color-border-control-default; + border-start-start-radius: awsui.$border-radius-token-inline; + border-start-end-radius: awsui.$border-radius-token-inline; + border-end-start-radius: awsui.$border-radius-token-inline; + border-end-end-radius: awsui.$border-radius-token-inline; + background-color: awsui.$color-background-control-default; + color: awsui.$color-text-body-default; + white-space: nowrap; + gap: awsui.$space-xxs; + @include styles.font-body-s; + + @include focus-visible.when-visible { + @include styles.focus-element-without-border(awsui.$border-radius-control-default-focus-ring); + } + + &:disabled { + border-color: awsui.$color-border-control-disabled; + background-color: awsui.$color-background-control-disabled; + color: awsui.$color-text-disabled; + cursor: auto; + } +} diff --git a/src/internal/components/autosuggest-input/test-classes/styles.scss b/src/internal/components/autosuggest-input/test-classes/styles.scss new file mode 100644 index 0000000000..88863d7ea3 --- /dev/null +++ b/src/internal/components/autosuggest-input/test-classes/styles.scss @@ -0,0 +1,27 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +.root { + /* used in test-utils */ +} + +.token-trigger { + /* used in test-utils */ +} + +.token-list { + /* used in test-utils */ +} + +.token-list-item { + /* used in test-utils */ +} + +.token-overflow-pill { + /* used in test-utils */ +} + +.overflow-panel { + /* used in test-utils */ +} diff --git a/src/internal/components/autosuggest-input/use-token-list-focus.ts b/src/internal/components/autosuggest-input/use-token-list-focus.ts new file mode 100644 index 0000000000..afeef65f5b --- /dev/null +++ b/src/internal/components/autosuggest-input/use-token-list-focus.ts @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useRef } from 'react'; + +interface UseTokenListFocusOptions { + tokenListRef: React.RefObject; + inputRef: React.RefObject; + tokenListLength: number; + effectiveVisible: number; +} + +interface UseTokenListFocusResult { + focusTokenAtIndex: (absoluteIndex: number) => void; +} + +/** + * Provides imperative focus management for the inline token list. + * + * `focusTokenAtIndex` translates an absolute token index (0-based within the full + * token array) to a position-in-visible index, then focuses the corresponding dismiss + * button. If the token is not visible (index falls before the visible window), focus + * is sent to the input instead. + * + * The snapshot ref is updated on every render so that keyboard handlers, which close + * over a stale `effectiveVisible`, always see the latest value. + */ +export function useTokenListFocus({ + tokenListRef, + inputRef, + tokenListLength, + effectiveVisible, +}: UseTokenListFocusOptions): UseTokenListFocusResult { + const snapshotRef = useRef({ tokenListLength, effectiveVisible }); + // Mutate every render so keyboard-handler callbacks capture the live values. + snapshotRef.current = { tokenListLength, effectiveVisible }; + + const focusTokenAtIndex = (absoluteIndex: number) => { + const { tokenListLength: len, effectiveVisible: ev } = snapshotRef.current; + const visibleStart = len - ev; + const positionInVisible = absoluteIndex - visibleStart; + if (positionInVisible < 0) { + inputRef.current?.focus(); + return; + } + const dismissButtons = tokenListRef.current?.querySelectorAll('[data-token-item] button'); + if (dismissButtons && dismissButtons[positionInVisible]) { + dismissButtons[positionInVisible].focus(); + } + }; + + return { focusTokenAtIndex }; +} diff --git a/src/internal/components/autosuggest-input/use-token-overflow.ts b/src/internal/components/autosuggest-input/use-token-overflow.ts new file mode 100644 index 0000000000..1ea5cb742a --- /dev/null +++ b/src/internal/components/autosuggest-input/use-token-overflow.ts @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLayoutEffect, useRef, useState } from 'react'; + +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; + +interface UseTokenOverflowResult { + containerRef: React.RefObject; + visibleCount: number | undefined; + tokenListMaxWidth: number | undefined; +} + +/** + * Measures token and container widths to determine how many tokens can be shown + * inline before the rest must collapse into the overflow "+N" pill. + * + * @param measureRef - ref to the invisible measurement container + * @param triggerRowRef - ref to the visible token trigger row (for padding/gap CSS) + * @param tokenCount - current total token count; changing it re-triggers measurement + * @param tokenContentKey - stable string derived from token labels/icons; changing it re-triggers measurement when content changes without count changing + * @param enabled - set to false in non-token mode to skip all measurement work + */ +export function useTokenOverflow( + measureRef: React.RefObject, + triggerRowRef: React.RefObject, + tokenCount: number, + tokenContentKey: string, + enabled: boolean +): UseTokenOverflowResult { + const containerRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(undefined); + const [tokenListMaxWidth, setTokenListMaxWidth] = useState(undefined); + const lastOverflowResultRef = useRef<{ count: number | undefined; maxWidth: number | undefined }>({ + count: undefined, + maxWidth: undefined, + }); + + const [containerResizeCount, setContainerResizeCount] = useState(0); + const lastContainerWidthRef = useRef(0); + useResizeObserver( + () => (enabled ? containerRef.current : null), + entry => { + if (entry.borderBoxWidth > 0 && entry.borderBoxWidth !== lastContainerWidthRef.current) { + lastContainerWidthRef.current = entry.borderBoxWidth; + setContainerResizeCount(n => n + 1); + } + } + ); + + useLayoutEffect(() => { + if (!enabled || !measureRef.current || !containerRef.current || !triggerRowRef.current) { + return; + } + measureRef.current.inert = true; + const containerWidth = containerRef.current.offsetWidth ?? 0; + if (containerWidth <= 0) { + return; + } + + const tokenEls = Array.from(measureRef.current.querySelectorAll('[data-measure-token]')); + if (tokenEls.length === 0) { + if (lastOverflowResultRef.current.count !== 0) { + lastOverflowResultRef.current = { count: 0, maxWidth: undefined }; + setVisibleCount(0); + setTokenListMaxWidth(undefined); + } + return; + } + + const pillEl = measureRef.current.querySelector('[data-measure-pill]'); + const pillWidth = pillEl?.offsetWidth ?? 0; + + const triggerEl = triggerRowRef.current!; + const triggerStyle = getComputedStyle(triggerEl); + const paddingInline = parseFloat(triggerStyle.paddingInlineStart) + parseFloat(triggerStyle.paddingInlineEnd); + const gap = parseFloat(triggerStyle.gap) || parseFloat(triggerStyle.columnGap); + + const iconEl = triggerEl.querySelector('[data-search-icon]')!; + const iconWidth = iconEl.offsetWidth; + + const inputMinWidth = Math.floor(containerWidth * 0.2); + + const tokenWidths = tokenEls.map(el => el.offsetWidth); + const totalAll = tokenWidths.reduce((s, w) => s + w, 0) + gap * Math.max(tokenWidths.length - 1, 0); + const budgetWithoutPill = containerWidth - paddingInline - iconWidth - inputMinWidth - gap; + const budgetWithPill = budgetWithoutPill - pillWidth - gap; + + let newCount: number; + let newMaxWidth: number | undefined; + + if (totalAll <= budgetWithoutPill) { + newCount = tokenCount; + newMaxWidth = undefined; + } else { + let used = 0; + let count = 0; + for (let i = tokenWidths.length - 1; i >= 0; i--) { + const cost = count === 0 ? tokenWidths[i] : gap + tokenWidths[i]; + if (used + cost > budgetWithPill) { + break; + } + used += cost; + count++; + } + newCount = count; + newMaxWidth = budgetWithPill + gap + pillWidth; + } + + if (newCount !== lastOverflowResultRef.current.count || newMaxWidth !== lastOverflowResultRef.current.maxWidth) { + lastOverflowResultRef.current = { count: newCount, maxWidth: newMaxWidth }; + setVisibleCount(newCount); + setTokenListMaxWidth(newMaxWidth); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, tokenCount, tokenContentKey, containerResizeCount]); + + return { containerRef, visibleCount, tokenListMaxWidth }; +} diff --git a/src/internal/styles/utils/mixins.scss b/src/internal/styles/utils/mixins.scss index 350b06ee56..c9cd8eb2dc 100644 --- a/src/internal/styles/utils/mixins.scss +++ b/src/internal/styles/utils/mixins.scss @@ -10,6 +10,16 @@ inset-inline-start: -9999px !important; } +@mixin measurement-ghost { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + block-size: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + @mixin text-wrapping { // When using with Flexbox, a flex item has min-width set to "auto" by default, which // prevents the text wrapping. We need to override the min-width by setting it to "0" diff --git a/src/test-utils/dom/autosuggest/index.ts b/src/test-utils/dom/autosuggest/index.ts index a90acb5dd6..5984083832 100644 --- a/src/test-utils/dom/autosuggest/index.ts +++ b/src/test-utils/dom/autosuggest/index.ts @@ -8,9 +8,11 @@ import InputWrapper from '../input'; import DropdownWrapper from '../internal/dropdown'; import OptionWrapper from '../internal/option'; import OptionsListWrapper from '../internal/options-list'; +import TokenWrapper from '../token'; import mainStyles from '../../../autosuggest/styles.selectors.js'; import dropdownStyles from '../../../dropdown/styles.selectors.js'; +import autosuggestInputTestUtilStyles from '../../../internal/components/autosuggest-input/test-classes/styles.selectors.js'; import dropdownStatusStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; import footerStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; import optionStyles from '../../../internal/components/option/styles.selectors.js'; @@ -177,4 +179,55 @@ export default class AutosuggestWrapper extends InputWrapper { .fireEvent(new MouseEvent('mouseup', { bubbles: true })); }); } + + /** + * Returns the token trigger row — the container element that holds the overflow pill, + * visible tokens, and text input when the component is in tokens mode. + * Returns `null` when the component is not in tokens mode (the `tokens` prop is absent). + */ + findTokenTrigger(): ElementWrapper | null { + return this.findByClassName(autosuggestInputTestUtilStyles['token-trigger']); + } + + /** + * Returns all visible inline token pills. Only available when the component is in tokens mode + * (the `tokens` prop is provided). + */ + findInlineTokens(): Array { + return this.findAllByClassName(autosuggestInputTestUtilStyles['token-list-item']).map( + (elementWrapper: ElementWrapper) => + new TokenWrapper(elementWrapper.findComponent(`.${TokenWrapper.rootSelector}`, TokenWrapper)!.getElement()) + ); + } + + /** + * Returns the inline token at a 1-based index. Only available when the component is in tokens mode + * (the `tokens` prop is provided). + * + * @param index 1-based index of the token to return. + */ + findInlineToken(index: number): TokenWrapper | null { + return this.findComponent( + `.${autosuggestInputTestUtilStyles['token-list']} > li[data-token-index="${index}"] .${TokenWrapper.rootSelector}`, + TokenWrapper + ); + } + + /** + * Returns the +N overflow pill shown when tokens overflow the available width. + * Returns `null` when all tokens are visible or the component is not in tokens mode. + */ + findOverflowPill(): ElementWrapper | null { + return this.find( + `.${autosuggestInputTestUtilStyles['token-list']} .${autosuggestInputTestUtilStyles['token-overflow-pill']}:not([data-measure-pill])` + ); + } + + /** + * Returns the overflow panel shown when the +N pill is clicked. + * Returns `null` when the overflow panel is closed. + */ + findOverflowPanel(): ElementWrapper | null { + return this.findByClassName(autosuggestInputTestUtilStyles['overflow-panel']); + } } diff --git a/src/test-utils/dom/internal/autosuggest-input.ts b/src/test-utils/dom/internal/autosuggest-input.ts index 96f69ab986..d582320490 100644 --- a/src/test-utils/dom/internal/autosuggest-input.ts +++ b/src/test-utils/dom/internal/autosuggest-input.ts @@ -1,16 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; +import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import InputWrapper from '../input'; import DropdownWrapper from './dropdown.js'; import dropdownStyles from '../../../dropdown/styles.selectors.js'; import inputStyles from '../../../input/styles.selectors.js'; -import styles from '../../../internal/components/autosuggest-input/styles.selectors.js'; +import testUtilStyles from '../../../internal/components/autosuggest-input/test-classes/styles.selectors.js'; export default class AutosuggestInputWrapper extends ComponentWrapper { - static rootSelector = styles.root; + static rootSelector = testUtilStyles.root; findInput(): InputWrapper { return this.findComponent(`.${inputStyles['input-container']}`, InputWrapper)!; @@ -19,4 +19,30 @@ export default class AutosuggestInputWrapper extends ComponentWrapper { findDropdown(): DropdownWrapper { return this.findComponent(`.${dropdownStyles.root}`, DropdownWrapper)!; } + + findTokenTrigger(): ElementWrapper | null { + return this.findByClassName(testUtilStyles['token-trigger']); + } + + findInlineTokens(): Array { + const tokenList = this.findByClassName(testUtilStyles['token-list']); + if (!tokenList) { + return []; + } + return tokenList.findAll('span[role]'); + } + + findInlineToken(index: number): ElementWrapper | null { + return this.find(`.${testUtilStyles['token-list']} > li[data-token-index="${index}"] span[role]`); + } + + findOverflowPill(): ElementWrapper | null { + return this.find( + `.${testUtilStyles['token-list']} .${testUtilStyles['token-overflow-pill']}:not([data-measure-pill])` + ); + } + + findOverflowPanel(): ElementWrapper | null { + return this.findByClassName(testUtilStyles['overflow-panel']); + } }