diff --git a/package-lock.json b/package-lock.json index 19b7fbdb38..8fd67e77ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -769,6 +769,7 @@ "version": "7.27.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1222,6 +1223,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1243,6 +1245,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1290,6 +1293,7 @@ "node_modules/@dnd-kit/core": { "version": "6.3.1", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2399,7 +2403,6 @@ "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -2463,7 +2466,6 @@ "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -2488,7 +2490,6 @@ "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" @@ -2503,7 +2504,6 @@ "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -4272,6 +4272,7 @@ "version": "9.6.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4471,6 +4472,7 @@ "version": "16.14.34", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4639,6 +4641,7 @@ "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.44.0", @@ -4669,6 +4672,7 @@ "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -4913,7 +4917,6 @@ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -4927,7 +4930,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -4942,8 +4944,7 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@wdio/config": { "version": "9.16.2", @@ -5008,6 +5009,7 @@ "version": "9.16.2", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.20.0" }, @@ -5028,6 +5030,7 @@ "version": "9.16.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.1.2", "loglevel": "^1.6.0", @@ -5335,6 +5338,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5385,6 +5389,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5949,6 +5954,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -6302,6 +6308,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", @@ -7377,6 +7384,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7877,7 +7885,6 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -8868,6 +8875,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8929,6 +8937,7 @@ "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9505,7 +9514,6 @@ "integrity": "sha512-7bc5I2dU3onKJaRhBdxKh/C+W+ot7R+RcRMCLTSR7cbfHM9Shk8ocbNDVvjrxaBdA52kbZONVSyhexp7cq2xNA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/snapshot": "^3.2.4", "deep-eql": "^5.0.2", @@ -9538,7 +9546,6 @@ "integrity": "sha512-5YUHr27fpJ64dnvtu+tt11ewATynrHkGYD+uSFgRr8V2eFJis/vEXgToyLwccIwqBihVfz9jwio+Zr1ab1Zihw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0" }, @@ -9552,7 +9559,6 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -9566,7 +9572,6 @@ "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -9585,8 +9590,7 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-webdriverio/node_modules/chalk": { "version": "4.1.2", @@ -9594,7 +9598,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9618,7 +9621,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -9629,7 +9631,6 @@ "integrity": "sha512-OKe7cdic4qbfWd/CcgwJvvCrNX2KWfuMZee9AfJHL1gTYmvqjBjZG1a2NwfhspBzxzlXwsN75WWpKTYfsJpBxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/expect-utils": "30.1.1", "@jest/get-type": "30.1.0", @@ -9648,7 +9649,6 @@ "integrity": "sha512-LUU2Gx8EhYxpdzTR6BmjL1ifgOAQJQELTHOiPv9KITaKjZvJ9Jmgigx01tuZ49id37LorpGc9dPBPlXTboXScw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", @@ -9665,7 +9665,6 @@ "integrity": "sha512-SuH2QVemK48BNTqReti6FtjsMPFsSOD/ZzRxU1TttR7RiRsRSe78d03bb4Cx6D4bQC/80Q8U4VnaaAH9FlbZ9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", @@ -9682,7 +9681,6 @@ "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.0.5", @@ -9704,7 +9702,6 @@ "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", @@ -9720,7 +9717,6 @@ "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", @@ -9739,7 +9735,6 @@ "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -9755,7 +9750,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9768,8 +9762,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/express": { "version": "4.21.2", @@ -12501,6 +12494,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -12865,6 +12859,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -13491,6 +13486,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -14836,6 +14832,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16132,6 +16129,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -16746,6 +16744,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17129,6 +17128,7 @@ "node_modules/react": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -17141,6 +17141,7 @@ "node_modules/react-dom": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -17814,6 +17815,7 @@ "version": "3.29.5", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -18479,6 +18481,7 @@ "version": "11.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^4.0.3", @@ -19198,6 +19201,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -19456,6 +19460,7 @@ "version": "7.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19838,6 +19843,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20004,7 +20010,6 @@ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -20231,7 +20236,8 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -20379,6 +20385,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20853,6 +20860,7 @@ "version": "9.16.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", @@ -20904,6 +20912,7 @@ "version": "5.99.9", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -20950,6 +20959,7 @@ "version": "5.1.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -21030,6 +21040,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21140,6 +21151,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21226,6 +21238,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/pages/prompt-input/prompt-input-integ.page.tsx b/pages/prompt-input/prompt-input-integ.page.tsx index 56b85be55d..dd9f60b34e 100644 --- a/pages/prompt-input/prompt-input-integ.page.tsx +++ b/pages/prompt-input/prompt-input-integ.page.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import PromptInput from '~components/prompt-input'; export default function Page() { - const [value, setValue] = useState(''); + const [value, setValue] = useState(''); const [submitStatus, setSubmitStatus] = useState(false); const [isKeyboardSubmittingDisabled, setDisableKeyboardSubmitting] = useState(false); @@ -25,7 +25,7 @@ export default function Page() { actionButtonIconName="send" actionButtonAriaLabel="Send" value={value} - onChange={event => setValue(event.detail.value)} + onChange={event => setValue(event.detail.value ?? '')} onAction={() => window.alert('Sent message!')} onKeyDown={event => { if (isKeyboardSubmittingDisabled) { diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx new file mode 100644 index 0000000000..a5ddf5ed9c --- /dev/null +++ b/pages/prompt-input/shortcuts.page.tsx @@ -0,0 +1,523 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useState } from 'react'; + +import { + AppLayout, + Box, + ButtonGroup, + ButtonGroupProps, + Checkbox, + ColumnLayout, + FileTokenGroup, + FormField, + PromptInput, + PromptInputProps, + SpaceBetween, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import labels from '../app-layout/utils/labels'; +import { i18nStrings } from '../file-upload/shared'; + +const MAX_CHARS = 2000; + +type DemoContext = React.Context< + AppContextType<{ + isDisabled: boolean; + isReadOnly: boolean; + isInvalid: boolean; + hasWarning: boolean; + hasText: boolean; + hasSecondaryContent: boolean; + hasSecondaryActions: boolean; + hasPrimaryActions: boolean; + hasInfiniteMaxRows: boolean; + disableActionButton: boolean; + disableBrowserAutocorrect: boolean; + enableSpellcheck: boolean; + hasName: boolean; + enableAutoFocus: boolean; + }> +>; + +const placeholderText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; + +// Sample data for menus +const mentionOptions = [ + { value: 'john', label: 'John Doe', description: 'Software Engineer' }, + { value: 'jane', label: 'Jane Smith', description: 'Product Manager' }, + { value: 'bob', label: 'Bob Johnson', description: 'Designer' }, + { value: 'alice', label: 'Alice Williams', description: 'Data Scientist' }, +]; + +const commandOptions = [ + { value: 'dev', label: 'Developer Mode', description: 'Optimized for code generation' }, + { value: 'creative', label: 'Creative Mode', description: 'Optimized for creative writing' }, + { value: 'analyze', label: 'Analyze Mode', description: 'Optimized for data analysis' }, + { value: 'summarize', label: 'Summarize Mode', description: 'Optimized for summarization' }, +]; + +const topicOptions = [ + { value: 'aws', label: 'AWS', description: 'Amazon Web Services' }, + { value: 'cloudscape', label: 'Cloudscape', description: 'Design system' }, + { value: 'react', label: 'React', description: 'JavaScript library' }, + { value: 'typescript', label: 'TypeScript', description: 'Typed JavaScript' }, + { value: 'accessibility', label: 'Accessibility', description: 'A11y best practices' }, + { value: 'performance', label: 'Performance', description: 'Optimization tips' }, +]; + +export default function PromptInputShortcutsPage() { + const [tokens, setTokens] = useState([]); + const [mode, setMode] = useState(); + const [plainTextValue, setPlainTextValue] = useState(''); + const [files, setFiles] = useState([]); + const [extractedText, setExtractedText] = useState(''); + const [selectionStart, setSelectionStart] = useState('0'); + const [selectionEnd, setSelectionEnd] = useState('0'); + + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + + const { + isDisabled, + isReadOnly, + isInvalid, + hasWarning, + hasText, + hasSecondaryActions, + hasSecondaryContent, + hasPrimaryActions, + hasInfiniteMaxRows, + disableActionButton, + disableBrowserAutocorrect, + enableSpellcheck, + hasName, + enableAutoFocus, + } = urlParams; + + const [items, setItems] = React.useState([ + { label: 'Item 1', dismissLabel: 'Remove item 1', disabled: isDisabled }, + { label: 'Item 2', dismissLabel: 'Remove item 2', disabled: isDisabled }, + { label: 'Item 3', dismissLabel: 'Remove item 3', disabled: isDisabled }, + ]); + + // Define menus for shortcuts + const menus = [ + { + id: 'mentions', + trigger: '@', + options: mentionOptions, + filteringType: 'auto' as const, + }, + { + id: 'mode', + trigger: '/', + options: commandOptions, + filteringType: 'auto' as const, + useAtStart: true, + }, + { + id: 'topics', + trigger: '#', + options: topicOptions, + filteringType: 'auto' as const, + }, + ]; + + useEffect(() => { + if (hasText) { + setTokens([{ type: 'text', value: placeholderText }]); + } + }, [hasText]); + + useEffect(() => { + if (plainTextValue !== placeholderText) { + setUrlParams({ hasText: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plainTextValue]); + + useEffect(() => { + if (items.length === 0) { + ref.current?.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + useEffect(() => { + const newItems = items.map(item => ({ + label: item.label, + dismissLabel: item.dismissLabel, + disabled: isDisabled, + })); + setItems([...newItems]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled]); + + const ref = React.createRef(); + + const buttonGroupRef = React.useRef(null); + + const onDismiss = (event: { detail: { fileIndex: number } }) => { + const newItems = [...files]; + newItems.splice(event.detail.fileIndex, 1); + setFiles(newItems); + }; + + return ( + +

PromptInput demo

+ + + setUrlParams({ isDisabled: !isDisabled })}> + Disabled + + setUrlParams({ isReadOnly: !isReadOnly })}> + Read-only + + setUrlParams({ isInvalid: !isInvalid })}> + Invalid + + setUrlParams({ hasWarning: !hasWarning })}> + Warning + + + setUrlParams({ + hasSecondaryContent: !hasSecondaryContent, + }) + } + > + Secondary content + + + setUrlParams({ + hasSecondaryActions: !hasSecondaryActions, + }) + } + > + Secondary actions + + + setUrlParams({ + hasPrimaryActions: !hasPrimaryActions, + }) + } + > + Custom primary actions + + + setUrlParams({ + hasInfiniteMaxRows: !hasInfiniteMaxRows, + }) + } + > + Infinite max rows + + + setUrlParams({ + disableActionButton: !disableActionButton, + }) + } + > + Disable action button + + + setUrlParams({ + disableBrowserAutocorrect: !disableBrowserAutocorrect, + }) + } + > + Disable browser autocorrect + + + setUrlParams({ + enableSpellcheck: !enableSpellcheck, + }) + } + > + Enable spellcheck + + + setUrlParams({ + hasName: !hasName, + }) + } + > + Has name attribute (for forms) + + + setUrlParams({ + enableAutoFocus: !enableAutoFocus, + }) + } + > + Enable auto focus + + + + + + + + + +
+ + + +
+ + {extractedText && ( + +
+ Last submitted text: + + {extractedText} + +
+
+ )} + + {tokens.length > 0 && ( + +
+ Current tokens: + + {JSON.stringify(tokens, null, 2)} + +
+
+ )} + + {mode && ( + +
+ Current mode: + + {JSON.stringify(mode, null, 2)} + +
+
+ )} + +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + console.log('FORM SUBMITTED (fallback):', { + 'user-prompt': formData.get('user-prompt'), + }); + }} + > + + MAX_CHARS || isInvalid) && 'The query has too many characters.'} + warningText={hasWarning && 'This input has a warning'} + constraintText={ + <> + This service is subject to some policy. Character count: {plainTextValue.length}/{MAX_CHARS} + + } + i18nStrings={{ errorIconAriaLabel: 'Error' }} + > + { + setMode(undefined); + }} + onChange={event => { + setTokens(event.detail.tokens); + setPlainTextValue(event.detail.value ?? ''); + }} + onAction={({ detail }) => { + setExtractedText(detail.value ?? ''); + // Clear tokens after submission, but keep the mode + setTokens([]); + setPlainTextValue(''); + + window.alert( + `Submitted:\n\nPlain text: ${detail.value ?? ''}\n\nTokens: ${JSON.stringify( + detail.tokens, + null, + 2 + )}` + ); + }} + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || plainTextValue.length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + disableActionButton={disableActionButton} + disableBrowserAutocorrect={disableBrowserAutocorrect} + spellcheck={enableSpellcheck} + name={hasName ? 'user-prompt' : undefined} + autoFocus={enableAutoFocus} + menus={menus} + onMenuItemSelect={event => { + console.log('Menu selection:', event.detail); + + // Find the menu definition to check if it creates mode tokens + const selectedMenu = menus?.find(menu => menu.id === event.detail.menuId); + + // Handle mode selection based on useAtStart property + if (selectedMenu?.useAtStart) { + const newMode = { + id: event.detail.option.value ?? '', + label: event.detail.option.label ?? event.detail.option.value ?? '', + value: event.detail.option.value ?? '', + }; + setMode(newMode); + } + }} + i18nStrings={{ + selectedMenuItemAriaLabel: 'Selected', + menuErrorIconAriaLabel: 'Error', + menuRecoveryText: 'Retry', + }} + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + + detail.id.includes('files') && setFiles(detail.files)} + items={[ + { + type: 'icon-file-input', + id: 'files', + text: 'Upload files', + multiple: true, + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Go full page', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'remove', + iconName: 'remove', + text: 'Remove', + disabled: isDisabled || isReadOnly, + }, + ]} + variant="icon" + /> + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> + +
+ + + +
+ } + /> + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 8b1f56e536..aab626316b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -18840,10 +18840,17 @@ exports[`Components definition for prompt-input matches the snapshot: prompt-inp { "cancelable": false, "description": "Called whenever a user clicks the action button or presses the "Enter" key. -The event \`detail\` contains the current value of the field.", +The event \`detail\` contains the current value as a string and an array of tokens. + +When \`menus\` is defined, the \`value\` is derived from \`tokensToText(tokens)\` if provided, otherwise from the default token-to-text conversion.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "PromptInputProps.ActionDetail", "properties": [ + { + "name": "tokens", + "optional": false, + "type": "Array", + }, { "name": "value", "optional": false, @@ -18852,7 +18859,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ActionDetail", "name": "onAction", }, { @@ -18863,10 +18870,17 @@ The event \`detail\` contains the current value of the field.", { "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.", +The event \`detail\` contains the current value as a string and an array of tokens. + +When \`menus\` is defined, the \`value\` is derived from \`tokensToText(tokens)\` if provided, otherwise from the default token-to-text conversion.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "PromptInputProps.ChangeDetail", "properties": [ + { + "name": "tokens", + "optional": false, + "type": "Array", + }, { "name": "value", "optional": false, @@ -18875,7 +18889,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ChangeDetail", "name": "onChange", }, { @@ -18981,6 +18995,348 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "detailType": "BaseKeyDetail", "name": "onKeyUp", }, + { + "cancelable": false, + "description": "Called when the user types to filter options in manual filtering mode for a menu. +Use this to filter the options based on the filtering text. + +The detail object contains: +- \`menuId\` - The ID of the menu that triggered the event. +- \`filteringText\` - The text to use for filtering options.", + "detailInlineType": { + "name": "PromptInputProps.MenuFilterDetail", + "properties": [ + { + "name": "filteringText", + "optional": false, + "type": "string", + }, + { + "name": "menuId", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuFilterDetail", + "name": "onMenuFilter", + }, + { + "cancelable": false, + "description": "Called whenever a user selects an option in a menu.", + "detailInlineType": { + "name": "PromptInputProps.MenuItemSelectDetail", + "properties": [ + { + "name": "menuId", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "OptionDefinition", + "properties": [ + { + "name": "__labelPrefix", + "optional": true, + "type": "string", + }, + { + "name": "description", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "name": "disabledReason", + "optional": true, + "type": "string", + }, + { + "name": "filteringTags", + "optional": true, + "type": "ReadonlyArray", + }, + { + "name": "iconAlt", + "optional": true, + "type": "string", + }, + { + "name": "iconAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "map", + "filter", + "key", + "file", + "pause", + "play", + "microphone", + "remove", + "copy", + "menu", + "script", + "close", + "status-pending", + "refresh", + "external", + "history", + "group", + "calendar", + "ellipsis", + "zoom-in", + "zoom-out", + "security", + "download", + "edit", + "add-plus", + "anchor-link", + "angle-left-double", + "angle-left", + "angle-right-double", + "angle-right", + "angle-up", + "angle-down", + "arrow-left", + "arrow-right", + "arrow-up", + "arrow-down", + "at-symbol", + "audio-full", + "audio-half", + "audio-off", + "backward-10-seconds", + "bug", + "call", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "closed-caption", + "closed-caption-unavailable", + "command-prompt", + "delete-marker", + "drag-indicator", + "envelope", + "exit-full-screen", + "expand", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "gen-ai", + "globe", + "grid-view", + "group-active", + "heart", + "heart-filled", + "insert-row", + "keyboard", + "list-view", + "location-pin", + "lock-private", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "redo", + "resize-area", + "settings", + "send", + "share", + "shrink", + "slash", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-not-started", + "status-positive", + "status-stopped", + "status-warning", + "stop-circle", + "subtract-minus", + "suggestions", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-to-fit", + ], + }, + "name": "iconName", + "optional": true, + "type": "string", + }, + { + "name": "iconSvg", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "iconUrl", + "optional": true, + "type": "string", + }, + { + "name": "label", + "optional": true, + "type": "string", + }, + { + "name": "labelContent", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "labelTag", + "optional": true, + "type": "string", + }, + { + "name": "lang", + "optional": true, + "type": "string", + }, + { + "name": "tags", + "optional": true, + "type": "ReadonlyArray", + }, + { + "name": "value", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "option", + "optional": false, + "type": "OptionDefinition", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuItemSelectDetail", + "name": "onMenuItemSelect", + }, + { + "cancelable": false, + "description": "Use this event to implement the asynchronous behavior for menus. + +The event is called in the following situations: +- The user scrolls to the end of the list of options, if \`statusType\` is set to \`pending\`. +- The user clicks on the recovery button in the error state. +- The user types after the trigger character. +- The menu is opened. + +The detail object contains the following properties: +- \`menuId\` - The ID of the menu that triggered the event. +- \`filteringText\` - The value that you need to use to fetch options. +- \`firstPage\` - Indicates that you should fetch the first page of options that match the \`filteringText\`. +- \`samePage\` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button).", + "detailInlineType": { + "name": "PromptInputProps.MenuLoadItemsDetail", + "properties": [ + { + "name": "filteringText", + "optional": false, + "type": "string", + }, + { + "name": "firstPage", + "optional": false, + "type": "boolean", + }, + { + "name": "menuId", + "optional": false, + "type": "string", + }, + { + "name": "samePage", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuLoadItemsDetail", + "name": "onMenuLoadItems", + }, + { + "cancelable": false, + "description": "Called when the user scrolls to the end of the options list in a menu and more items are available. +Use this to load additional pages of options for pagination. + +The detail object contains the \`menuId\` of the menu that triggered the event.", + "detailInlineType": { + "name": "PromptInputProps.MenuLoadMoreItemsDetail", + "properties": [ + { + "name": "menuId", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuLoadMoreItemsDetail", + "name": "onMenuLoadMoreItems", + }, + { + "cancelable": false, + "description": "Called when the user removes the active mode.", + "name": "onModeRemoved", + }, ], "functions": [ { @@ -18989,6 +19345,22 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "parameters": [], "returnType": "void", }, + { + "description": "Inserts text at the current cursor position (or at a specified position). +This properly triggers keyboard and input events, including menu detection when \`menus\` is defined.", + "name": "insertText", + "parameters": [ + { + "name": "text", + "type": "string", + }, + { + "name": "position", + "type": "number", + }, + ], + "returnType": "void", + }, { "description": "Selects all text in the textarea control.", "name": "select", @@ -19022,6 +19394,7 @@ common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-set "name": "PromptInput", "properties": [ { + "deprecatedTag": "Use \`i18nStrings.actionButtonAriaLabel\` instead.", "description": "Adds an aria-label to the action button.", "i18nTag": true, "name": "actionButtonAriaLabel", @@ -19201,9 +19574,9 @@ and set the property to a string of each ID separated by spaces (for example, \` "type": "string", }, { - "description": "Adds an \`aria-label\` to the native control. - -Use this if you don't have a visible label for this control.", + "deprecatedTag": "Use \`i18nStrings.ariaLabel\` instead.", + "description": "Adds an aria-label to the input element.", + "i18nTag": true, "name": "ariaLabel", "optional": true, "type": "string", @@ -19232,7 +19605,9 @@ In some cases it might be appropriate to disable autocomplete (for example, for To use it correctly, set the \`name\` property. You can either provide a boolean value to set the property to "on" or "off", or specify a string value -for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute.", +for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute. + +Note: When \`menus\` is defined, autocomplete will not function.", "inlineType": { "name": "string | boolean", "type": "union", @@ -19310,6 +19685,82 @@ receive focus.", "optional": true, "type": "boolean", }, + { + "description": "By default, the menu height is constrained to fit inside the height of its next scrollable container element. +Enabling this property will allow the menu to extend beyond that container by using fixed positioning and +[React Portals](https://reactjs.org/docs/portals.html). + +Set this property if the menu would otherwise be constrained by a scrollable container, +for example inside table and split view layouts. + +We recommend you use discretion, and don't enable this property unless necessary +because fixed positioning results in a slight, visible lag when scrolling complex pages.", + "name": "expandMenusToViewport", + "optional": true, + "type": "boolean", + }, + { + "description": "An object containing all the localized strings required by the component. + +- \`ariaLabel\` (string) - Adds an aria-label to the input element. +- \`actionButtonAriaLabel\` (string) - Adds an aria-label to the action button. +- \`menuErrorIconAriaLabel\` (string) - Provides a text alternative for the error icon in the error message in menus. +- \`menuRecoveryText\` (string) - Specifies the text for the recovery button in menus. The text is displayed next to the error text. +- \`menuLoadingText\` (string) - Specifies the text to display when menus are in a loading state. +- \`menuFinishedText\` (string) - Specifies the text to display when menus have finished loading all items. +- \`menuErrorText\` (string) - Specifies the text to display when menus encounter an error while loading. +- \`selectedMenuItemAriaLabel\` (string) - Specifies the localized string that describes an option as being selected.", + "i18nTag": true, + "inlineType": { + "name": "PromptInputProps.I18nStrings", + "properties": [ + { + "name": "actionButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "name": "menuErrorIconAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "menuErrorText", + "optional": true, + "type": "string", + }, + { + "name": "menuFinishedText", + "optional": true, + "type": "string", + }, + { + "name": "menuLoadingText", + "optional": true, + "type": "string", + }, + { + "name": "menuRecoveryText", + "optional": true, + "type": "string", + }, + { + "name": "selectedMenuItemAriaLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "PromptInputProps.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 @@ -19337,6 +19788,13 @@ Defaults to 3. Use -1 for infinite rows.", "optional": true, "type": "number", }, + { + "description": "Menus that can be triggered via specific symbols (e.g., "/" or "@"). +For menus only relevant to triggers at the start of the input, set \`useAtStart: true\`, defaults to \`false\`.", + "name": "menus", + "optional": true, + "type": "Array", + }, { "defaultValue": "1", "description": "Specifies the minimum number of lines of text to set the height to.", @@ -19345,7 +19803,37 @@ Defaults to 3. Use -1 for infinite rows.", "type": "number", }, { - "description": "Specifies the name of the control used in HTML forms.", + "description": "Specifies the active mode (e.g., /dev, /creative).", + "inlineType": { + "name": "PromptInputProps.ModeToken", + "properties": [ + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "label", + "optional": false, + "type": "string", + }, + { + "name": "value", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "name": "mode", + "optional": true, + "type": "PromptInputProps.ModeToken", + }, + { + "description": "Specifies the name of the prompt input for form submissions. + +When \`tokens\` is set, the value will be the \`tokensToText\` output if provided, +else it will be the concatenated \`value\` properties from \`tokens\`.", "name": "name", "optional": true, "type": "string", @@ -19356,7 +19844,8 @@ Some attributes will be automatically combined with internal attribute values: - \`className\` will be appended. - Event handlers will be chained, unless the default is prevented. -We do not support using this attribute to apply custom styling.", +We do not support using this attribute to apply custom styling. +If \`tokens\` is defined, nativeTextareaAttributes will be ignored.", "inlineType": { "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", "type": "union", @@ -19388,6 +19877,35 @@ Don't use read-only inputs outside a form.", "optional": true, "type": "boolean", }, + { + "description": "Overrides the element that is announced to screen readers in menus +when the highlighted option changes. By default, this announces +the option's name and properties, and its selected state if +the \`selectedLabel\` property is defined. +The highlighted option is provided, and its group (if groups +are used and it differs from the group of the previously highlighted option). + +For more information, see the +[accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines).", + "inlineType": { + "name": "AutosuggestProps.ContainingOptionAndGroupString", + "parameters": [ + { + "name": "option", + "type": "OptionDefinition", + }, + { + "name": "group", + "type": "AutosuggestProps.OptionGroup", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderHighlightedMenuItemAriaLive", + "optional": true, + "type": "AutosuggestProps.ContainingOptionAndGroupString", + }, { "description": "Specifies the value of the \`spellcheck\` attribute on the native control. This value controls the native browser capability to check for spelling/grammar errors. @@ -19632,7 +20150,45 @@ inadvertently sending data (such as user passwords) to third parties.", "type": "PromptInputProps.Style", }, { - "description": "Specifies the text entered into the form element.", + "description": "Specifies the content of the prompt input when using token mode. + +All tokens use the same unified structure with a \`value\` property: +- Text tokens: \`value\` contains the text content +- Reference tokens: \`value\` contains the reference value, \`label\` for display (e.g., '@john')", + "name": "tokens", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "Custom function to transform tokens into plain text for the \`value\` field in \`onChange\` and \`onAction\` events +and for the hidden input when \`name\` is specified. + +If not provided, the default implementation is: +\`\`\` +tokens.map(token => token.value).join(''); +\`\`\` + +Use this to customize serialization, for example: +- Using \`label\` instead of \`value\` for reference tokens +- Adding custom formatting or separators between tokens", + "inlineType": { + "name": "(tokens: ReadonlyArray) => string", + "parameters": [ + { + "name": "tokens", + "type": "ReadonlyArray", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokensToText", + "optional": true, + "type": "((tokens: ReadonlyArray) => string)", + }, + { + "description": "Specifies the content of the prompt input. +When \`tokens\` is defined, this represents the plain text equivalent of the tokens.", "name": "value", "optional": false, "type": "string", @@ -37804,6 +38360,21 @@ If not specified, the method returns the result text that is currently displayed ], }, }, + { + "description": "Finds the contentEditable element used when menus are defined. +Returns null if the component does not have menus defined.", + "name": "findContentEditableElement", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLDivElement", + }, + ], + }, + }, { "name": "findCustomPrimaryAction", "parameters": [], @@ -37818,6 +38389,29 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "name": "findMenu", + "parameters": [ + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "PromptInputMenuWrapper", + }, + }, + { + "description": "Finds the native textarea element. + +Note: When menus are defined, the component uses a contentEditable element instead of a textarea. +In this case, this method may fail to find the textarea element. Use findContentEditableElement() +or the getValue()/setValue() methods instead.", "name": "findNativeTextarea", "parameters": [], "returnType": { @@ -37857,11 +38451,19 @@ If not specified, the method returns the result text that is currently displayed ], }, }, + { + "name": "getTextareaValue", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "string", + }, + }, { "description": "Gets the value of the component. -Returns the current value of the textarea.", - "name": "getTextareaValue", +Returns the current value of the textarea (when no menus are defined) or the text content of the contentEditable element (when menus are defined).", + "name": "getValue", "parameters": [], "returnType": { "isNullable": false, @@ -37869,7 +38471,78 @@ Returns the current value of the textarea.", }, }, { - "description": "Sets the value of the component and calls the onChange handler.", + "name": "isMenuOpen", + "parameters": [ + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + { + "description": "Selects an option from the menu by simulating mouse events.", + "name": "selectMenuOption", + "parameters": [ + { + "description": "1-based index of the option to select", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { + "description": "Selects an option from the menu by simulating mouse events.", + "name": "selectMenuOptionByValue", + "parameters": [ + { + "description": "value of option to select", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + { + "defaultValue": "{ expandMenusToViewport: false }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { "name": "setTextareaValue", "parameters": [ { @@ -37886,9 +38559,96 @@ Returns the current value of the textarea.", "name": "void", }, }, + { + "description": "Sets the value of the component by directly setting text content. +This does NOT trigger menu detection. Use the component ref's insertText() method +to simulate typing and trigger menus.", + "name": "setValue", + "parameters": [ + { + "description": "String value to set the component to.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "name": "findOpenMenu", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns an option from the menu.", + "name": "findOption", + "parameters": [ + { + "description": "1-based index of the option to select.", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionWrapper", + }, + }, + { + "description": "Returns an option from the menu by its value", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The 'value' of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionWrapper", + }, + }, + { + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "OptionWrapper", + }, + ], + }, + }, + ], + "name": "PromptInputMenuWrapper", + }, { "methods": [ { @@ -46917,6 +47677,16 @@ If not specified, the method returns the result text that is currently displayed "name": "ElementWrapper", }, }, + { + "description": "Finds the contentEditable element used when menus are defined. +Returns null if the component does not have menus defined.", + "name": "findContentEditableElement", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findCustomPrimaryAction", "parameters": [], @@ -46926,6 +47696,31 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "name": "findMenu", + "parameters": [ + { + "defaultValue": "{ + expandMenusToViewport: false + }", + "description": "* expandMenusToViewport (boolean) - Use this when the component under test is rendered with an \`expandMenusToViewport\` flag.", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ expandMenusToViewport: boolean; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "PromptInputMenuWrapper", + }, + }, + { + "description": "Finds the native textarea element. + +Note: When menus are defined, the component uses a contentEditable element instead of a textarea. +In this case, this method may fail to find the textarea element. Use findContentEditableElement() +or the getValue()/setValue() methods instead.", "name": "findNativeTextarea", "parameters": [], "returnType": { @@ -46953,6 +47748,68 @@ If not specified, the method returns the result text that is currently displayed ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "name": "findOpenMenu", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns an option from the menu.", + "name": "findOption", + "parameters": [ + { + "description": "1-based index of the option to select.", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionWrapper", + }, + }, + { + "description": "Returns an option from the menu by its value", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The 'value' of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionWrapper", + }, + }, + { + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "OptionWrapper", + }, + ], + }, + }, + ], + "name": "PromptInputMenuWrapper", + }, { "methods": [ { diff --git a/src/internal/components/dropdown/dropdown-fit-handler.ts b/src/internal/components/dropdown/dropdown-fit-handler.ts index ffb1af613e..26d84495bd 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -232,6 +232,7 @@ export const getDropdownPosition = ({ stretchHeight = false, isMobile = false, stretchBeyondTriggerWidth = false, + forcePosition, }: { triggerElement: HTMLElement; dropdownElement: HTMLElement; @@ -242,6 +243,7 @@ export const getDropdownPosition = ({ stretchHeight?: boolean; isMobile?: boolean; stretchBeyondTriggerWidth?: boolean; + forcePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; }): DropdownPosition => { // Determine the space available around the dropdown that it can grow in const availableSpace = getAvailableSpace({ @@ -262,19 +264,24 @@ export const getDropdownPosition = ({ let insetInlineStart: number | null = null; let inlineSize = idealWidth; - //1. Can it be positioned with ideal width to the right? - if (idealWidth <= availableSpace.inlineEnd) { - dropInlineStart = false; - //2. Can it be positioned with ideal width to the left? - } else if (idealWidth <= availableSpace.inlineStart) { - dropInlineStart = true; - //3. Fit into biggest available space either on left or right + // Handle forced horizontal position + if (forcePosition) { + dropInlineStart = forcePosition.endsWith('-left'); } else { - dropInlineStart = availableSpace.inlineStart > availableSpace.inlineEnd; - inlineSize = Math.max(availableSpace.inlineStart, availableSpace.inlineEnd, minWidth); + //1. Can it be positioned with ideal width to the right? + if (idealWidth <= availableSpace.inlineEnd) { + dropInlineStart = false; + //2. Can it be positioned with ideal width to the left? + } else if (idealWidth <= availableSpace.inlineStart) { + dropInlineStart = true; + //3. Fit into biggest available space either on left or right + } else { + dropInlineStart = availableSpace.inlineStart > availableSpace.inlineEnd; + inlineSize = Math.max(availableSpace.inlineStart, availableSpace.inlineEnd, minWidth); + } } - if (preferCenter) { + if (preferCenter && !forcePosition) { const spillOver = (idealWidth - triggerInlineSize) / 2; // availableSpace always includes the trigger width, but we want to exclude that @@ -287,8 +294,9 @@ export const getDropdownPosition = ({ } } - const dropBlockStart = - availableSpace.blockEnd < dropdownElement.offsetHeight && availableSpace.blockStart > availableSpace.blockEnd; + const dropBlockStart = forcePosition + ? forcePosition.startsWith('top-') + : availableSpace.blockEnd < dropdownElement.offsetHeight && availableSpace.blockStart > availableSpace.blockEnd; const availableHeight = dropBlockStart ? availableSpace.blockStart : availableSpace.blockEnd; // Try and crop the bottom item when all options can't be displayed, affordance for "there's more" const croppedHeight = Math.max(stretchHeight ? availableHeight : Math.floor(availableHeight / 31) * 31 + 16, 15); @@ -361,7 +369,8 @@ export const calculatePosition = ( stretchHeight: boolean, isMobile: boolean, minWidth?: number, - stretchBeyondTriggerWidth?: boolean + stretchBeyondTriggerWidth?: boolean, + forcePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' ): [DropdownPosition, LogicalDOMRect] => { // cleaning previously assigned values, // so that they are not reused in case of screen resize and similar events @@ -393,6 +402,7 @@ export const calculatePosition = ( stretchHeight, isMobile, stretchBeyondTriggerWidth, + forcePosition, }); const triggerBox = getLogicalBoundingClientRect(triggerElement); return [position, triggerBox]; diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index 7dbc402aeb..509a289ad6 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -177,6 +177,8 @@ const Dropdown = ({ dropdownContentRole, ariaLabelledby, ariaDescribedby, + forcePosition, + forceMobile = false, }: DropdownProps) => { const wrapperRef = useRef(null); const triggerRef = useRef(null); @@ -236,7 +238,7 @@ const Dropdown = ({ position, dropdownElement: target, triggerRect: triggerBox, - isMobile, + isMobile: forceMobile || isMobile, }); // Keep track of the initial dropdown position and direction. // Dropdown direction doesn't need to change as the user scrolls, just needs to stay attached to the trigger. @@ -328,7 +330,8 @@ const Dropdown = ({ stretchHeight, isMobile, minWidth, - stretchBeyondTriggerWidth + stretchBeyondTriggerWidth, + forcePosition ), dropdownRef.current, verticalContainerRef.current @@ -389,7 +392,7 @@ const Dropdown = ({ position: fixedPosition.current, dropdownElement: dropdownRef.current, triggerRect: getLogicalBoundingClientRect(triggerRef.current), - isMobile, + isMobile: forceMobile || isMobile, }); } }; @@ -402,7 +405,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport, isMobile]); + }, [open, expandToViewport, isMobile, forceMobile]); const referrerId = useUniqueId(); diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index 27c611d0b9..b59ac01363 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -155,6 +155,16 @@ export interface DropdownProps extends ExpandToViewport { * Describedby for the dropdown (recommended when role="dialog") */ ariaDescribedby?: string; + + /** + * Force a specific dropdown position + */ + forcePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + + /** + * Force mobile behavior (useful for full-width dropdowns with groups) + */ + forceMobile?: boolean; } export interface ExpandToViewport { diff --git a/src/prompt-input/index.tsx b/src/prompt-input/index.tsx index af9857f233..975a374e20 100644 --- a/src/prompt-input/index.tsx +++ b/src/prompt-input/index.tsx @@ -10,10 +10,9 @@ import InternalPromptInput from './internal'; export { PromptInputProps }; -const PromptInput = React.forwardRef( +const PromptInput = React.forwardRef( ( { - autoComplete, autoFocus, disableBrowserAutocorrect, disableActionButton, @@ -23,13 +22,12 @@ const PromptInput = React.forwardRef( minRows = 1, maxRows = 3, ...props - }: PromptInputProps, - ref: React.Ref + }, + ref ) => { const baseComponentProps = useBaseComponent('PromptInput', { props: { readOnly, - autoComplete, autoFocus, disableBrowserAutocorrect, disableActionButton, @@ -42,7 +40,6 @@ const PromptInput = React.forwardRef( return ( , + extends Omit, InputKeyEvents, InputAutoCorrect, - InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { + /** + * Specifies the name of the prompt input for form submissions. + * + * When `tokens` is set, the value will be the `tokensToText` output if provided, + * else it will be the concatenated `value` properties from `tokens`. + */ + name?: string; + + /** + * Specifies whether to enable a browser's autocomplete functionality for this input. + * In some cases it might be appropriate to disable autocomplete (for example, for security-sensitive fields). + * To use it correctly, set the `name` property. + * + * You can either provide a boolean value to set the property to "on" or "off", or specify a string value + * for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute. + * + * Note: When `menus` is defined, autocomplete will not function. + */ + autoComplete?: boolean | string; + + /** + * Specifies the content of the prompt input. + * When `tokens` is defined, this represents the plain text equivalent of the tokens. + */ + value: string; + + /** + * Specifies the content of the prompt input when using token mode. + * + * All tokens use the same unified structure with a `value` property: + * - Text tokens: `value` contains the text content + * - Reference tokens: `value` contains the reference value, `label` for display (e.g., '@john') + */ + tokens?: readonly PromptInputProps.InputToken[]; + + /** + * Specifies the active mode (e.g., /dev, /creative). + */ + mode?: PromptInputProps.ModeToken; + + /** + * Called when the user removes the active mode. + */ + onModeRemoved?: NonCancelableEventHandler; + + /** + * Custom function to transform tokens into plain text for the `value` field in `onChange` and `onAction` events + * and for the hidden input when `name` is specified. + * + * If not provided, the default implementation is: + * ``` + * tokens.map(token => token.value).join(''); + * ``` + * + * Use this to customize serialization, for example: + * - Using `label` instead of `value` for reference tokens + * - Adding custom formatting or separators between tokens + */ + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + + /** + * Called whenever a user changes the input value (by typing or pasting). + * The event `detail` contains the current value as a string and an array of tokens. + * + * When `menus` is defined, the `value` is derived from `tokensToText(tokens)` if provided, otherwise from the default token-to-text conversion. + */ + onChange?: NonCancelableEventHandler; + /** * Called whenever a user clicks the action button or presses the "Enter" key. - * The event `detail` contains the current value of the field. + * The event `detail` contains the current value as a string and an array of tokens. + * + * When `menus` is defined, the `value` is derived from `tokensToText(tokens)` if provided, otherwise from the default token-to-text conversion. */ onAction?: NonCancelableEventHandler; + /** * Determines what icon to display in the action button. */ actionButtonIconName?: IconProps.Name; + /** * Specifies the URL of a custom icon. Use this property if the icon you want isn't available. * * If you set both `actionButtonIconUrl` and `actionButtonIconSvg`, `actionButtonIconSvg` will take precedence. */ actionButtonIconUrl?: string; + /** * Specifies the SVG of a custom icon. * @@ -62,14 +133,24 @@ export interface PromptInputProps * In most cases, they aren't needed, as the `svg` element inherits styles from the icon component. */ actionButtonIconSvg?: React.ReactNode; + /** * Specifies alternate text for a custom icon. We recommend that you provide this for accessibility. * This property is ignored if you use a predefined icon or if you set your custom icon using the `iconSvg` slot. */ actionButtonIconAlt?: string; + + /** + * Adds an aria-label to the input element. + * @i18n + * @deprecated Use `i18nStrings.ariaLabel` instead. + */ + ariaLabel?: string; + /** * Adds an aria-label to the action button. * @i18n + * @deprecated Use `i18nStrings.actionButtonAriaLabel` instead. */ actionButtonAriaLabel?: string; @@ -118,6 +199,93 @@ export interface PromptInputProps */ disableSecondaryContentPaddings?: boolean; + /** + * Menus that can be triggered via specific symbols (e.g., "/" or "@"). + * For menus only relevant to triggers at the start of the input, set `useAtStart: true`, defaults to `false`. + */ + menus?: PromptInputProps.MenuDefinition[]; + + /** + * Called whenever a user selects an option in a menu. + */ + onMenuItemSelect?: NonCancelableEventHandler; + + /** + * Use this event to implement the asynchronous behavior for menus. + * + * The event is called in the following situations: + * - The user scrolls to the end of the list of options, if `statusType` is set to `pending`. + * - The user clicks on the recovery button in the error state. + * - The user types after the trigger character. + * - The menu is opened. + * + * The detail object contains the following properties: + * - `menuId` - The ID of the menu that triggered the event. + * - `filteringText` - The value that you need to use to fetch options. + * - `firstPage` - Indicates that you should fetch the first page of options that match the `filteringText`. + * - `samePage` - Indicates that you should fetch the same page that you have previously fetched (for example, when the user clicks on the recovery button). + */ + onMenuLoadItems?: NonCancelableEventHandler; + + /** + * Called when the user scrolls to the end of the options list in a menu and more items are available. + * Use this to load additional pages of options for pagination. + * + * The detail object contains the `menuId` of the menu that triggered the event. + */ + onMenuLoadMoreItems?: NonCancelableEventHandler; + + /** + * Called when the user types to filter options in manual filtering mode for a menu. + * Use this to filter the options based on the filtering text. + * + * The detail object contains: + * - `menuId` - The ID of the menu that triggered the event. + * - `filteringText` - The text to use for filtering options. + */ + onMenuFilter?: NonCancelableEventHandler; + + /** + * An object containing all the localized strings required by the component. + * + * - `ariaLabel` (string) - Adds an aria-label to the input element. + * - `actionButtonAriaLabel` (string) - Adds an aria-label to the action button. + * - `menuErrorIconAriaLabel` (string) - Provides a text alternative for the error icon in the error message in menus. + * - `menuRecoveryText` (string) - Specifies the text for the recovery button in menus. The text is displayed next to the error text. + * - `menuLoadingText` (string) - Specifies the text to display when menus are in a loading state. + * - `menuFinishedText` (string) - Specifies the text to display when menus have finished loading all items. + * - `menuErrorText` (string) - Specifies the text to display when menus encounter an error while loading. + * - `selectedMenuItemAriaLabel` (string) - Specifies the localized string that describes an option as being selected. + * @i18n + */ + i18nStrings?: PromptInputProps.I18nStrings; + + /** + * Overrides the element that is announced to screen readers in menus + * when the highlighted option changes. By default, this announces + * the option's name and properties, and its selected state if + * the `selectedLabel` property is defined. + * The highlighted option is provided, and its group (if groups + * are used and it differs from the group of the previously highlighted option). + * + * For more information, see the + * [accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines). + */ + renderHighlightedMenuItemAriaLive?: AutosuggestProps.ContainingOptionAndGroupString; + + /** + * By default, the menu height is constrained to fit inside the height of its next scrollable container element. + * Enabling this property will allow the menu to extend beyond that container by using fixed positioning and + * [React Portals](https://reactjs.org/docs/portals.html). + * + * Set this property if the menu would otherwise be constrained by a scrollable container, + * for example inside table and split view layouts. + * + * We recommend you use discretion, and don't enable this property unless necessary + * because fixed positioning results in a slight, visible lag when scrolling complex pages. + */ + expandMenusToViewport?: boolean; + /** * Attributes to add to the native `textarea` element. * Some attributes will be automatically combined with internal attribute values: @@ -125,6 +293,7 @@ export interface PromptInputProps * - Event handlers will be chained, unless the default is prevented. * * We do not support using this attribute to apply custom styling. + * If `tokens` is defined, nativeTextareaAttributes will be ignored. * * @awsuiSystem core */ @@ -138,7 +307,139 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; - export type ActionDetail = BaseChangeDetail; + + export interface I18nStrings { + ariaLabel?: string; + actionButtonAriaLabel?: string; + menuErrorIconAriaLabel?: string; + menuRecoveryText?: string; + menuLoadingText?: string; + menuFinishedText?: string; + menuErrorText?: string; + selectedMenuItemAriaLabel?: string; + } + + export interface TextToken { + type: 'text'; + value: string; + } + + export interface ReferenceToken { + type: 'reference'; + id: string; + label: string; + value: string; + } + + export type ModeToken = Omit; + + export type InputToken = TextToken | ReferenceToken; + + export interface ChangeDetail { + value: string; + tokens: InputToken[]; + } + + export interface ActionDetail { + value: string; + tokens: InputToken[]; + } + + export interface MenuItemSelectDetail { + menuId: string; + option: OptionDefinition; + } + + export interface MenuLoadItemsDetail { + menuId: string; + filteringText: string; + firstPage: boolean; + samePage: boolean; + } + + export interface MenuLoadMoreItemsDetail { + menuId: string; + } + + export interface MenuFilterDetail { + menuId: string; + filteringText: string; + } + + export interface MenuDefinition + extends Pick, + Pick { + /** + * The unique identifier for this menu. + */ + id: string; + + /** + * The unique trigger symbol for showing this menu. + */ + trigger: string; + + /** + * Set `useAtStart=true` for menus where a trigger should only be detected at the start of input. + * Set this for menus designated to modes or actions. + * + * Menus with `useAtStart=true` create tokens with `type='mode'`. + */ + useAtStart?: boolean; + + /** + * Specifies an array of options that are displayed to the user as a list. + * The options can be grouped using `OptionGroup` objects. + * + * #### Option + * - `value` (string) - The returned value of the option when selected. + * - `label` (string) - (Optional) Option text displayed to the user. + * - `lang` (string) - (Optional) The language of the option, provided as a BCP 47 language tag. + * - `description` (string) - (Optional) Further information about the option that appears below the label. + * - `disabled` (boolean) - (Optional) Determines whether the option is disabled. + * - `labelTag` (string) - (Optional) A label tag that provides additional guidance, shown next to the label. + * - `tags` [string[]] - (Optional) A list of tags giving further guidance about the option. + * - `filteringTags` [string[]] - (Optional) A list of additional tags used for automatic filtering. + * - `iconName` (string) - (Optional) Specifies the name of an [icon](/components/icon/) to display in the option. + * - `iconAriaLabel` (string) - (Optional) Specifies alternate text for the icon. We recommend that you provide this for accessibility. + * - `iconAlt` (string) - (Optional) **Deprecated**, replaced by \`iconAriaLabel\`. Specifies alternate text for a custom icon, for use with `iconUrl`. + * - `iconUrl` (string) - (Optional) URL of a custom icon. + * - `iconSvg` (ReactNode) - (Optional) Custom SVG icon. Equivalent to the `svg` slot of the [icon component](/components/icon/). + * + * #### OptionGroup + * - `label` (string) - Option group text displayed to the user. + * - `disabled` (boolean) - (Optional) Determines whether the option group is disabled. + * - `options` (Option[]) - (Optional) The options under this group. + * + * Note: Only one level of option nesting is supported. + * + * If you want to use the built-in filtering capabilities of this component, provide + * a list of all valid options here and they will be automatically filtered based on the user's filtering input. + * + * Alternatively, you can listen to the `onChange` or `onLoadItems` event and set new options + * on your own. + */ + options: OptionDefinition[]; + + /** + * Determines how filtering is applied to the list of `options`: + * + * - `auto` - The component will automatically filter options based on user input. + * - `manual` - You will set up `onMenuFilter` event listeners and filter options on your side or request + * them from server. + * + * By default the component will filter the provided `options` based on the value of the filtering input field. + * Only options that have a `value`, `label`, `description` or `labelTag` that contains the input value as a substring + * are displayed in the list of options. + * + * If you set this property to `manual`, this default filtering mechanism is disabled and all provided `options` are + * displayed in the menu. In that case make sure that you use the `onMenuFilter` event in order + * to set the `options` property to the options that are relevant for the user, given the filtering input value. + * + * Note: Manual filtering doesn't disable match highlighting. + **/ + filteringType?: Exclude; + } export interface Ref { /** @@ -159,6 +460,12 @@ export namespace PromptInputProps { * common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-setselectionrange-incompatible-with-react-hooks */ setSelectionRange(start: number | null, end: number | null, direction?: 'forward' | 'backward' | 'none'): void; + + /** + * Inserts text at the current cursor position (or at a specified position). + * This properly triggers keyboard and input events, including menu detection when `menus` is defined. + */ + insertText(text: string, position?: number): void; } export interface Style { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index aa76b69f98..48cd310c9b 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -1,31 +1,41 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; import clsx from 'clsx'; -import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; +import { useDensityMode, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; import { convertAutoComplete } from '../input/utils'; import { getBaseProps } from '../internal/base-component'; +import Dropdown from '../internal/components/dropdown'; +import DropdownFooter from '../internal/components/dropdown-footer'; +import { useDropdownStatus } from '../internal/components/dropdown-status'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; -import * as tokens from '../internal/generated/styles/tokens'; +import * as designTokens from '../internal/generated/styles/tokens'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { SomeRequired } from '../internal/types'; import WithNativeAttributes from '../internal/utils/with-native-attributes'; import { PromptInputProps } from './interfaces'; +import { MenuItem, useMenuItems } from './menus/menu-controller'; +import { useMenuLoadMore } from './menus/menu-load-more-controller'; +import MenuOptionsList from './menus/menu-options-list'; import { getPromptInputStyles } from './styles'; +import { getPromptText } from './tokens/token-utils'; +import { useEditableTokens } from './tokens/use-editable-tokens'; +import { createCursorManager } from './utils/cursor-utils'; +import { createKeyboardHandlers } from './utils/keyboard-handlers'; import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; - interface InternalPromptInputProps extends SomeRequired, InternalBaseComponentProps {} -const InternalPromptInput = React.forwardRef( +const InternalPromptInput = React.forwardRef( ( { value, @@ -35,8 +45,8 @@ const InternalPromptInput = React.forwardRef( actionButtonIconSvg, actionButtonIconAlt, ariaLabel, - autoComplete, autoFocus, + autoComplete, disableActionButton, disableBrowserAutocorrect, disabled, @@ -59,41 +69,350 @@ const InternalPromptInput = React.forwardRef( disableSecondaryContentPaddings, nativeTextareaAttributes, style, + tokens, + tokensToText, + mode, + onModeRemoved, + menus, + onMenuItemSelect, + onMenuFilter, + onMenuLoadItems, + onMenuLoadMoreItems, + i18nStrings, + expandMenusToViewport, __internalRootRef, ...rest }: InternalPromptInputProps, - ref: Ref + ref ) => { const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = useFormFieldContext(rest); - const baseProps = getBaseProps(rest); + // i18n strings with fallback to deprecated properties + const effectiveActionButtonAriaLabel = i18nStrings?.actionButtonAriaLabel ?? actionButtonAriaLabel; + + // Menu state - event-driven trigger detection + // Only open menu when trigger character is pressed, then track filter text + const [detectedTrigger, setDetectedTrigger] = useState<{ + menuId: string; + filterText: string; + triggerPosition: number; + } | null>(null); + + // Derive menu state from detected trigger + const activeMenu = useMemo( + () => (detectedTrigger ? (menus?.find(m => m.id === detectedTrigger.menuId) ?? null) : null), + [detectedTrigger, menus] + ); + const menuIsOpen = !!activeMenu; + const menuFilterText = detectedTrigger?.filterText ?? ''; + const menuTriggerPosition = detectedTrigger?.triggerPosition ?? 0; + + // Refs const textareaRef = useRef(null); + const editableElementRef = useRef(null); + const reactContainersRef = useRef>(new Set()); + // Mode detection const isRefresh = useVisualRefresh(); - const isCompactMode = useDensityMode(textareaRef) === 'compact'; + useDensityMode(textareaRef); + useDensityMode(editableElementRef); + const isTokenMode = !!menus; + + // Style constants + const PADDING = isRefresh ? designTokens.spaceXxs : designTokens.spaceXxxs; + const LINE_HEIGHT = designTokens.lineHeightBodyM; - const PADDING = isRefresh ? tokens.spaceXxs : tokens.spaceXxxs; - const LINE_HEIGHT = tokens.lineHeightBodyM; - const DEFAULT_MAX_ROWS = 3; + // Ref to store the keydown handler for insertText method + const keydownHandlerRef = useRef<((event: React.KeyboardEvent) => void) | null>(null); useImperativeHandle( ref, () => ({ focus(...args: Parameters) { - textareaRef.current?.focus(...args); + if (isTokenMode) { + editableElementRef.current?.focus(...args); + } else { + textareaRef.current?.focus(...args); + } }, select() { - textareaRef.current?.select(); + if (isTokenMode) { + const selection = window.getSelection(); + const range = document.createRange(); + if (editableElementRef.current) { + range.selectNodeContents(editableElementRef.current); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } else { + textareaRef.current?.select(); + } }, setSelectionRange(...args: Parameters) { - textareaRef.current?.setSelectionRange(...args); + if (isTokenMode && editableElementRef.current) { + const [start, end] = args; + const cursorManager = createCursorManager(editableElementRef.current); + + if (end !== undefined && end !== null && end !== start) { + cursorManager.setRange(start ?? 0, end); + } else { + cursorManager.setPosition(start ?? 0); + } + } else { + textareaRef.current?.setSelectionRange(...args); + } + }, + insertText(text: string, position?: number) { + if (!isTokenMode || !editableElementRef.current || !keydownHandlerRef.current) { + return; + } + + const element = editableElementRef.current; + + // Position cursor if specified + if (position !== undefined) { + const cursorManager = createCursorManager(element); + cursorManager.setPosition(position); + } + + // Insert each character to trigger menu detection + for (const char of text) { + // Trigger keydown event + const keydownEvent = { + key: char, + ctrlKey: false, + metaKey: false, + altKey: false, + nativeEvent: new KeyboardEvent('keydown', { key: char, bubbles: true, cancelable: true }), + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.KeyboardEvent; + + keydownHandlerRef.current(keydownEvent); + + // Insert character at cursor + const selection = window.getSelection(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const textNode = document.createTextNode(char); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection.removeAllRanges(); + selection.addRange(range); + } else { + // JSDOM fallback - append to end and try to restore selection + element.textContent = (element.textContent || '') + char; + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + // Trigger input event + element.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); + } }, }), - [textareaRef] + [isTokenMode] ); - const handleKeyDown = (event: React.KeyboardEvent) => { + /** + * Dynamically adjusts the input height based on content and row constraints. + */ + const adjustInputHeight = useCallback(() => { + const element = isTokenMode ? editableElementRef.current : textareaRef.current; + if (!element) { + return; + } + + // Preserve scroll position for token mode + const scrollTop = element.scrollTop; + element.style.height = 'auto'; + + const minRowsHeight = isTokenMode + ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})` + : `calc(${LINE_HEIGHT} + ${designTokens.spaceScaledXxs} * 2)`; + const scrollHeight = `calc(${element.scrollHeight}px)`; + + if (maxRows === -1) { + element.style.height = `max(${scrollHeight}, ${minRowsHeight})`; + } else { + const effectiveMaxRows = maxRows <= 0 ? 3 : maxRows; + const maxRowsHeight = `calc(${effectiveMaxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; + element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; + } + + if (isTokenMode) { + element.scrollTop = scrollTop; + } + }, [isTokenMode, minRows, maxRows, LINE_HEIGHT, PADDING]); + + // Callback for updating filter text when menu is open + // Uses the wrapper element to track the trigger region + const updateMenuFilterText = useCallback(() => { + if (!detectedTrigger || !editableElementRef.current) { + return; + } + + // Find the trigger wrapper element + const wrapper = editableElementRef.current.querySelector( + `[data-menu-trigger="${detectedTrigger.menuId}"]` + ) as HTMLElement; + + if (!wrapper) { + // Wrapper was removed (e.g., user deleted it) - close menu + setDetectedTrigger(null); + return; + } + + // Check if cursor is inside the wrapper + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + const cursorNode = range.startContainer; + + // Walk up to see if we're inside the wrapper + let isInsideWrapper = false; + let node: Node | null = cursorNode; + while (node && node !== editableElementRef.current) { + if (node === wrapper) { + isInsideWrapper = true; + break; + } + node = node.parentNode; + } + + if (!isInsideWrapper) { + // Cursor moved outside wrapper - close menu + setDetectedTrigger(null); + return; + } + + // Extract filter text from wrapper (everything after the trigger char) + const wrapperText = wrapper.textContent || ''; + const newFilterText = wrapperText.substring(1); // Skip trigger character + + // Update filter text if changed + if (newFilterText !== detectedTrigger.filterText) { + setDetectedTrigger({ + ...detectedTrigger, + filterText: newFilterText, + }); + + // Fire filter event + if (onMenuFilter) { + fireNonCancelableEvent(onMenuFilter, { + menuId: detectedTrigger.menuId, + filteringText: newFilterText, + }); + } + } + }, [detectedTrigger, onMenuFilter]); + + // Track desired cursor position for controlled updates (e.g., after menu selection) + // This is in cursor space (tokens = 1 char) + const [desiredCursorPosition, setDesiredCursorPosition] = useState(null); + + // Reset cursor position after it's been applied + useEffect(() => { + if (desiredCursorPosition !== null) { + // Reset after the cursor has been positioned + const timer = setTimeout(() => setDesiredCursorPosition(null), 0); + return () => clearTimeout(timer); + } + }, [desiredCursorPosition, tokens]); + + // Virtual tokens array: [mode, ...inputTokens] - represents actual DOM structure + // This is the single source of truth for all cursor space operations + interface VirtualToken { + type: 'mode' | 'text' | 'reference'; + value: string; + label?: string; + id?: string; + } + const virtualTokens = useMemo(() => { + const result: VirtualToken[] = []; + if (mode) { + result.push({ type: 'mode', value: mode.value, label: mode.label, id: mode.id }); + } + for (const token of tokens ?? []) { + result.push(token as VirtualToken); + } + return result; + }, [mode, tokens]); + + // Use ref for virtualTokens to avoid recreating callbacks + const virtualTokensRef = useRef(virtualTokens); + virtualTokensRef.current = virtualTokens; + + // Convert cursor space position to DOM space position + // Cursor space: mode/reference tokens = 1 char, text = actual length + // DOM space: only TEXT NODES count (tokens are contentEditable=false so they're skipped) + // Use ref to avoid recreating this function on every render + const cursorSpaceToDomSpace = useCallback((cursorSpacePos: number): number => { + let domPos = 0; + let cursorPos = 0; + + for (const token of virtualTokensRef.current) { + if (cursorPos >= cursorSpacePos) { + break; + } + + if (token.type === 'text') { + const remaining = cursorSpacePos - cursorPos; + const tokenLength = token.value.length; + + if (remaining <= tokenLength) { + // Cursor is within this text token + domPos += remaining; + break; + } + + domPos += tokenLength; + cursorPos += tokenLength; + } else { + // Mode or reference token - these are contentEditable=false so they don't count in DOM position + // Just increment cursor position, but NOT dom position + cursorPos += 1; + } + } + + return domPos; + }, []); + + // Helper to get plain text value from tokens or value prop + const getPlainTextValue = useCallback(() => { + if (isTokenMode) { + return tokensToText ? tokensToText(tokens ?? []) : getPromptText(tokens ?? []); + } + return value; + }, [isTokenMode, tokensToText, tokens, value]); + + // Convert cursor space position to DOM space for useEditableTokens + const domCursorPosition = desiredCursorPosition !== null ? cursorSpaceToDomSpace(desiredCursorPosition) : null; + + // Use the editable hook as interface layer between contentEditable DOM and React + const { handleInput } = useEditableTokens({ + elementRef: editableElementRef, + reactContainersRef, + tokens, + mode, + tokensToText, + onChange: detail => fireNonCancelableEvent(onChange, detail), + onModeRemoved: onModeRemoved ? () => fireNonCancelableEvent(onModeRemoved) : undefined, + adjustInputHeight, + disabled: !isTokenMode, + cursorPosition: domCursorPosition, + }); + + const handleTextareaKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { @@ -101,52 +420,595 @@ const InternalPromptInput = React.forwardRef( event.currentTarget.form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value }); + fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); } }; - const handleChange = (event: React.ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: event.target.value }); - adjustTextareaHeight(); + const handleTextareaChange = (event: React.ChangeEvent) => { + fireNonCancelableEvent(onChange, { + value: event.target.value, + tokens: isTokenMode ? [...(tokens ?? [])] : [], + }); + adjustInputHeight(); }; - const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; + const handleEditableElementKeyDown = useCallback( + (event: React.KeyboardEvent) => { + fireKeyboardEvent(onKeyDown, event); + + if (keyboardHandlers) { + // Handle menu navigation first + if (keyboardHandlers.handleMenuNavigation(event)) { + return; + } + + // Handle Enter key for form submission + keyboardHandlers.handleEnterKey(event); + } + + if (keyboardHandlers) { + // Handle Backspace for token deletion + if (keyboardHandlers.handleBackspaceKey(event)) { + return; + } + + // Handle mode token deletion + if (event.key === 'Backspace') { + const selection = window.getSelection(); + if (!selection?.rangeCount || !selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + let nodeToCheck = range.startContainer; + + if (nodeToCheck.nodeType === Node.TEXT_NODE && range.startOffset === 0) { + nodeToCheck = nodeToCheck.previousSibling as Node; + } else if (nodeToCheck === editableElementRef.current && range.startOffset > 0) { + nodeToCheck = editableElementRef.current.childNodes[range.startOffset - 1]; + } + + if (nodeToCheck?.nodeType === Node.TEXT_NODE && nodeToCheck.textContent === '') { + const previousNode = nodeToCheck.previousSibling; + if (previousNode?.nodeType === Node.ELEMENT_NODE) { + const element = previousNode as Element; + if (element.hasAttribute('data-token-type')) { + nodeToCheck = previousNode; + } + } + } + + keyboardHandlers.handleModeBackspace(event, nodeToCheck); + } + } + + // EVENT-DRIVEN TRIGGER DETECTION + // Check if the pressed key is a menu trigger character + if (menus && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + const triggerChar = event.key; + const matchingMenu = menus.find(m => m.trigger === triggerChar); + + if (matchingMenu && editableElementRef.current) { + // Get cursor position in DOM space + const cursorManager = createCursorManager(editableElementRef.current); + const domCursorPosition = cursorManager.getPosition(); + + // Convert DOM position to cursor space position + // Walk through tokens and count characters + let cursorSpacePosition = 0; + let domCharCount = 0; + + for (const token of virtualTokensRef.current) { + if (token.type === 'text') { + const tokenLength = token.value.length; + if (domCharCount + tokenLength >= domCursorPosition) { + // Cursor is within this text token + cursorSpacePosition += domCursorPosition - domCharCount; + break; + } + domCharCount += tokenLength; + cursorSpacePosition += tokenLength; + } else { + // Non-text tokens: count their label length in DOM but only 1 in cursor space + const domLength = token.label?.length || token.value.length; + if (domCharCount + domLength >= domCursorPosition) { + // Cursor is within or after this token - treat as after the token + cursorSpacePosition += 1; + break; + } + domCharCount += domLength; + cursorSpacePosition += 1; + } + } + + // Build text in cursor space to check position validity + let textInCursorSpace = ''; + for (const token of virtualTokensRef.current) { + if (token.type === 'text') { + textInCursorSpace += token.value; + } else { + textInCursorSpace += '\uFFFC'; + } + } + + // Check if trigger is valid at this position + let isValidPosition = false; + + if (matchingMenu.useAtStart) { + // Must be at position 0 (start of input) OR position 1 if there's a mode token + // This allows changing the mode by typing a new mode trigger + const hasModeToken = virtualTokensRef.current[0]?.type === 'mode'; + isValidPosition = cursorSpacePosition === 0 || (hasModeToken && cursorSpacePosition === 1); + } else { + // Must be at start or after whitespace + const charBefore = textInCursorSpace[cursorSpacePosition - 1]; + isValidPosition = cursorSpacePosition === 0 || /\s/.test(charBefore || ''); + } + + if (isValidPosition) { + // Wrap the trigger in a temporary element for easy tracking + // Wait for the trigger character to be inserted first + setTimeout(() => { + if (!editableElementRef.current) { + return; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + // Get current cursor position (should be right after the trigger char) + const range = selection.getRangeAt(0); + const cursorNode = range.startContainer; + const cursorOffset = range.startOffset; + + // Find the text node containing the trigger + let textNode: Text | null = null; + let triggerOffset = 0; + + if (cursorNode.nodeType === Node.TEXT_NODE) { + textNode = cursorNode as Text; + // Trigger should be right before cursor + triggerOffset = cursorOffset - 1; + } else if (cursorNode.nodeType === Node.ELEMENT_NODE) { + // Cursor is between elements or at start/end + // Look for the text node at the cursor position + const childNodes = Array.from(cursorNode.childNodes); + const nodeAtCursor = childNodes[cursorOffset - 1]; + + if (nodeAtCursor?.nodeType === Node.TEXT_NODE) { + textNode = nodeAtCursor as Text; + triggerOffset = (textNode.textContent?.length || 0) - 1; + } + } - const adjustTextareaHeight = useCallback(() => { - if (textareaRef.current) { - // this is required so the scrollHeight becomes dynamic, otherwise it will be locked at the highest value for the size it reached e.g. 500px - textareaRef.current.style.height = 'auto'; + if (!textNode || triggerOffset < 0) { + return; + } - const minTextareaHeight = `calc(${LINE_HEIGHT} + ${tokens.spaceScaledXxs} * 2)`; // the min height of Textarea with 1 row + // Create a wrapper span for the trigger region + const wrapper = document.createElement('span'); + wrapper.setAttribute('data-menu-trigger', matchingMenu.id); + wrapper.style.display = 'inline'; - if (maxRows === -1) { - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `max(${scrollHeight}, ${minTextareaHeight})`; + // Split the text node at the trigger position + const beforeTrigger = textNode.textContent?.substring(0, triggerOffset) || ''; + const triggerAndAfter = textNode.textContent?.substring(triggerOffset) || ''; + + // Find where the trigger region ends (next whitespace or end) + let endOffset = 1; // Start after trigger char + while (endOffset < triggerAndAfter.length && !/\s/.test(triggerAndAfter[endOffset])) { + endOffset++; + } + + const triggerRegion = triggerAndAfter.substring(0, endOffset); + const afterTrigger = triggerAndAfter.substring(endOffset); + + // Replace the text node with: beforeText + wrapper(triggerRegion) + afterText + const parent = textNode.parentNode; + if (!parent) { + return; + } + + const fragment = document.createDocumentFragment(); + + if (beforeTrigger) { + fragment.appendChild(document.createTextNode(beforeTrigger)); + } + + wrapper.textContent = triggerRegion; + fragment.appendChild(wrapper); + + if (afterTrigger) { + fragment.appendChild(document.createTextNode(afterTrigger)); + } + + parent.replaceChild(fragment, textNode); + + // Place cursor at end of wrapper (after trigger char) + const newRange = document.createRange(); + newRange.setStart(wrapper.firstChild || wrapper, 1); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + + // Open menu + setDetectedTrigger({ + menuId: matchingMenu.id, + filterText: '', + triggerPosition: cursorSpacePosition, + }); + }, 0); + } + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [menus, onKeyDown] + ); + + // Store keydown handler in ref for insertText method + useEffect(() => { + keydownHandlerRef.current = handleEditableElementKeyDown; + }, [handleEditableElementKeyDown]); + + const handleEditableElementBlur = useCallback(() => { + // Close menu on blur + setDetectedTrigger(null); + + if (onBlur) { + fireNonCancelableEvent(onBlur); + } + }, [onBlur]); + + // Handle window resize + useEffect(() => { + const handleResize = () => adjustInputHeight(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [adjustInputHeight]); + + // Auto-focus on mount + useEffect(() => { + if (isTokenMode && autoFocus && editableElementRef.current) { + editableElementRef.current.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Cleanup React containers on unmount + useEffect(() => { + const containers = reactContainersRef.current; + return () => { + containers.forEach(container => ReactDOM.unmountComponentAtNode(container)); + containers.clear(); + }; + }, []); + + // Handle menu option selection - update tokens and let reactive system handle DOM/cursor + const handleMenuSelect = useCallback( + (option: MenuItem) => { + if (!activeMenu || !detectedTrigger || !editableElementRef.current) { + return; + } + + // Find and remove the trigger wrapper element + const wrapper = editableElementRef.current.querySelector( + `[data-menu-trigger="${detectedTrigger.menuId}"]` + ) as HTMLElement; + + if (wrapper) { + // Remove the wrapper, leaving its content will be handled by token update + wrapper.remove(); + } + + const triggerLength = 1 + detectedTrigger.filterText.length; // trigger char + filter text + const isMode = activeMenu.useAtStart; + + // Use the memoized virtualTokens - single source of truth + let newVirtualTokens: VirtualToken[]; + let cursorPosition: number; + + if (isMode) { + // Mode: add mode token and remove trigger text from first text token + const modeToken: VirtualToken = { + type: 'mode', + id: option.option.value || '', + label: option.option.label || option.option.value || '', + value: option.option.value || '', + }; + + // Check if there's already a mode token + const existingModeIndex = virtualTokens.findIndex(t => t.type === 'mode'); + const hasExistingMode = existingModeIndex !== -1; + + const firstTextIndex = virtualTokens.findIndex(t => t.type === 'text'); + + if (firstTextIndex >= 0) { + const firstToken = virtualTokens[firstTextIndex]; + const afterTrigger = firstToken.value.substring(triggerLength); + + if (hasExistingMode) { + // Replace existing mode token + newVirtualTokens = [ + modeToken, + ...(afterTrigger ? [{ type: 'text' as const, value: afterTrigger }] : []), + ...virtualTokens.slice(firstTextIndex + 1), + ]; + } else { + // Insert new mode token + newVirtualTokens = [ + modeToken, + ...virtualTokens.slice(0, firstTextIndex), + ...(afterTrigger ? [{ type: 'text' as const, value: afterTrigger }] : []), + ...virtualTokens.slice(firstTextIndex + 1), + ]; + } + } else { + // No text tokens, just replace or add mode token + newVirtualTokens = [modeToken, ...virtualTokens.filter(t => t.type !== 'mode')]; + } + + // Cursor after mode token = 1 + cursorPosition = 1; } else { - const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `min(max(${scrollHeight}, ${minTextareaHeight}), ${maxRowsHeight})`; + // Reference: remove trigger and insert reference token + const newToken: VirtualToken = { + type: 'reference', + id: option.option.value || '', + label: option.option.label || option.option.value || '', + value: option.option.value || '', + }; + + // Find token containing trigger position (in cursor space) + let currentPos = 0; + let insertIndex = -1; + let insertOffset = 0; + + for (let i = 0; i < virtualTokens.length; i++) { + const token = virtualTokens[i]; + const tokenLength = token.type === 'text' ? token.value.length : 1; + + if (currentPos <= menuTriggerPosition && currentPos + tokenLength > menuTriggerPosition) { + insertIndex = i; + insertOffset = menuTriggerPosition - currentPos; + break; + } + + currentPos += tokenLength; + } + + newVirtualTokens = []; + + if (insertIndex === -1) { + // Trigger not found, append at end + newVirtualTokens.push(...virtualTokens, newToken, { type: 'text', value: ' ' }); + } else { + // Insert at trigger position + for (let i = 0; i < virtualTokens.length; i++) { + if (i < insertIndex) { + newVirtualTokens.push(virtualTokens[i]); + } else if (i === insertIndex) { + const token = virtualTokens[i]; + if (token.type === 'text') { + const beforeTrigger = token.value.substring(0, insertOffset); + const afterTrigger = token.value.substring(insertOffset + triggerLength); + + if (beforeTrigger) { + newVirtualTokens.push({ type: 'text', value: beforeTrigger }); + } + newVirtualTokens.push(newToken); + if (afterTrigger) { + newVirtualTokens.push({ type: 'text', value: afterTrigger }); + } else { + newVirtualTokens.push({ type: 'text', value: ' ' }); + } + } + } else { + newVirtualTokens.push(virtualTokens[i]); + } + } + } + + // Cursor: trigger position + 1 (reference token) + 1 (trailing space) + cursorPosition = menuTriggerPosition + 1 + 1; + } + + // DON'T update virtualTokensRef here - let it update from props + // This ensures cursor conversion uses the correct token array + + // Split virtualTokens back into mode and tokens + const newTokens: PromptInputProps.InputToken[] = newVirtualTokens + .filter(t => t.type !== 'mode') + .map(t => { + if (t.type === 'text') { + return { type: 'text', value: t.value }; + } else { + return { type: 'reference', id: t.id!, label: t.label!, value: t.value }; + } + }); + + // Update tokens via onChange - reactive system will handle DOM and cursor + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + fireNonCancelableEvent(onChange, { + value, + tokens: newTokens, + }); + + // Notify parent about the selection + fireNonCancelableEvent(onMenuItemSelect, { + menuId: activeMenu.id, + option: option.option, + }); + + // Set desired cursor position for reactive update + setDesiredCursorPosition(cursorPosition); + + // Clear trigger - menu will close automatically + setDetectedTrigger(null); + }, + [activeMenu, detectedTrigger, tokensToText, onChange, onMenuItemSelect, virtualTokens, menuTriggerPosition] + ); + + // Menu items controller - always call hooks + const menuItemsResult = useMenuItems({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + filterText: menuFilterText, + onSelectItem: handleMenuSelect, + }); + + // Keep menu items state stable to prevent dropdown from unmounting during state updates + const [menuItemsState, menuItemsHandlers] = menuItemsResult; + const stableMenuItemsState = activeMenu ? menuItemsState : null; + const stableMenuItemsHandlers = activeMenu ? menuItemsHandlers : null; + + // Handle token deletion at cursor + const handleDeleteTokenAtCursor = useCallback(() => { + if (!editableElementRef.current || !tokens) { + return false; + } + + const cursorManager = createCursorManager(editableElementRef.current); + const cursorPosition = cursorManager.getPosition(); + + let currentPos = 0; + let tokenIndexToDelete = -1; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const tokenLength = token.type === 'text' ? token.value.length : 1; + + if (cursorPosition > currentPos && cursorPosition <= currentPos + tokenLength) { + if (token.type !== 'text') { + tokenIndexToDelete = i; + break; + } + } else if (cursorPosition === currentPos && token.type !== 'text') { + tokenIndexToDelete = i; + break; } + + currentPos += tokenLength; + } + + if (tokenIndexToDelete >= 0) { + const newTokens = [...tokens]; + newTokens.splice(tokenIndexToDelete, 1); + + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + fireNonCancelableEvent(onChange, { + value, + tokens: newTokens, + }); + + return true; } - }, [maxRows, LINE_HEIGHT, PADDING]); + return false; + }, [tokens, tokensToText, onChange]); + + // Create keyboard handlers + const keyboardHandlers = useMemo(() => { + if (!editableElementRef.current) { + return null; + } + + return createKeyboardHandlers({ + menuOpen: menuIsOpen, + menuItemsState: stableMenuItemsState, + menuItemsHandlers: stableMenuItemsHandlers, + onAction: onAction ? detail => fireNonCancelableEvent(onAction, detail) : undefined, + onModeRemoved: onModeRemoved ? () => fireNonCancelableEvent(onModeRemoved) : undefined, + tokensToText, + tokens, + getPromptText, + deleteTokenAtCursor: handleDeleteTokenAtCursor, + closeMenu: () => { + setDetectedTrigger(null); + }, + }); + }, [ + menuIsOpen, + stableMenuItemsState, + stableMenuItemsHandlers, + onAction, + onModeRemoved, + tokensToText, + tokens, + handleDeleteTokenAtCursor, + ]); + + // Menu load more controller - always call hooks + const menuLoadMoreResult = useMenuLoadMore({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + statusType: activeMenu?.statusType ?? 'finished', + onLoadItems: detail => { + fireNonCancelableEvent(onMenuLoadItems, detail); + }, + onLoadMoreItems: () => { + fireNonCancelableEvent(onMenuLoadMoreItems, { + menuId: activeMenu?.id ?? '', + }); + }, + }); + + const menuLoadMoreHandlers = activeMenu ? menuLoadMoreResult : null; + + // Fire load items when menu opens + useEffect(() => { + if (menuIsOpen && activeMenu && menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnMenuOpen(); + } + }, [menuIsOpen, activeMenu, menuLoadMoreHandlers]); + + // Update filter text on cursor movement or content change when menu is open useEffect(() => { - const handleResize = () => { - adjustTextareaHeight(); + if (!isTokenMode || !editableElementRef.current || !detectedTrigger) { + return; + } + + const handleSelectionChange = () => { + updateMenuFilterText(); }; - window.addEventListener('resize', handleResize); + // Also update on any DOM mutations (content changes) + const observer = new MutationObserver(() => { + updateMenuFilterText(); + }); + + observer.observe(editableElementRef.current, { + childList: true, + characterData: true, + subtree: true, + }); + document.addEventListener('selectionchange', handleSelectionChange); return () => { - window.removeEventListener('resize', handleResize); + document.removeEventListener('selectionchange', handleSelectionChange); + observer.disconnect(); }; - }, [adjustTextareaHeight]); + }, [isTokenMode, detectedTrigger, updateMenuFilterText]); - useEffect(() => { - adjustTextareaHeight(); - }, [value, adjustTextareaHeight, maxRows, isCompactMode]); + const hasActionButton = !!( + actionButtonIconName || + actionButtonIconSvg || + actionButtonIconUrl || + customPrimaryAction + ); + + // Show placeholder in token mode when input is empty (no mode, no tokens with content) + const showPlaceholder = isTokenMode && placeholder && !mode && (!tokens || tokens.length === 0); - const attributes: React.TextareaHTMLAttributes = { + const textareaAttributes: React.TextareaHTMLAttributes = { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, @@ -159,37 +1021,92 @@ const InternalPromptInput = React.forwardRef( [styles.warning]: warning, }), autoComplete: convertAutoComplete(autoComplete), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, spellCheck: spellcheck, disabled, readOnly: readOnly ? true : undefined, rows: minRows, - onKeyDown: handleKeyDown, - onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - // We set a default value on the component in order to force it into the controlled mode. value: value || '', - onChange: handleChange, + onKeyDown: handleTextareaKeyDown, + onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), + onChange: handleTextareaChange, onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; - if (disableBrowserAutocorrect) { - attributes.autoCorrect = 'off'; - attributes.autoCapitalize = 'off'; - } + const editableElementAttributes: React.HTMLAttributes & { + 'data-placeholder'?: string; + } = { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + 'aria-invalid': invalid ? 'true' : undefined, + 'aria-disabled': disabled ? 'true' : undefined, + 'aria-readonly': readOnly ? 'true' : undefined, + 'aria-required': rest.ariaRequired ? 'true' : undefined, + 'data-placeholder': placeholder, + className: clsx(styles.textarea, testutilStyles.textarea, { + [styles.invalid]: invalid, + [styles.warning]: warning, + [styles['textarea-disabled']]: disabled, + [styles['placeholder-visible']]: showPlaceholder, + }), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, + spellCheck: spellcheck, + onKeyDown: handleEditableElementKeyDown, + onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), + onBlur: handleEditableElementBlur, + onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), + }; + + // Menu dropdown setup + const menuListId = useUniqueId('menu-list'); + const menuFooterControlId = useUniqueId('menu-footer'); + const highlightedMenuOptionIdSource = useUniqueId(); + const highlightedMenuOptionId = stableMenuItemsState?.highlightedOption ? highlightedMenuOptionIdSource : undefined; + + // Always call useDropdownStatus hook + const menuDropdownStatusResult = useDropdownStatus({ + ...(activeMenu ?? {}), + isEmpty: !stableMenuItemsState || stableMenuItemsState.items.length === 0, + recoveryText: i18nStrings?.menuRecoveryText, + errorIconAriaLabel: i18nStrings?.menuErrorIconAriaLabel, + onRecoveryClick: () => { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnRecoveryClick(); + } + editableElementRef.current?.focus(); + }, + hasRecoveryCallback: Boolean(onMenuLoadItems), + }); + + const menuDropdownStatus = activeMenu ? menuDropdownStatusResult : null; - const action = ( + // Keep dropdown open while menu is active, even with 0 filtered results + // This prevents flickering during filtering + // Use stableMenuItemsState to prevent unmounting during state transitions + const shouldRenderMenuDropdown = useMemo( + () => menuIsOpen && activeMenu && stableMenuItemsState, + [menuIsOpen, activeMenu, stableMenuItemsState] + ); + + const actionButton = (
{customPrimaryAction ?? ( fireNonCancelableEvent(onAction, { value })} + onClick={() => { + fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); + }} variant="icon" /> )} @@ -221,17 +1138,88 @@ const InternalPromptInput = React.forwardRef( {secondaryContent}
)} +
- - {hasActionButton && !secondaryActions && action} + {isTokenMode ? ( + <> + {name && } +
+ { + event.preventDefault(); + }} + trigger={ +
+ } + footer={ + menuDropdownStatus?.isSticky && menuDropdownStatus.content ? ( + = 1 : false} + /> + ) : null + } + > + {shouldRenderMenuDropdown && stableMenuItemsState && stableMenuItemsHandlers && activeMenu && ( + { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnScroll(); + } + }} + hasDropdownStatus={menuDropdownStatus?.content !== null} + selectedMenuItemAriaLabel={i18nStrings?.selectedMenuItemAriaLabel} + renderHighlightedMenuItemAriaLive={rest.renderHighlightedMenuItemAriaLive} + listBottom={ + !menuDropdownStatus?.isSticky ? ( + + ) : null + } + ariaDescribedby={menuDropdownStatus?.content ? menuFooterControlId : undefined} + /> + )} + +
+ + ) : ( + + )} + {hasActionButton && !secondaryActions && actionButton}
+ {secondaryActions && (
{secondaryActions}
-
textareaRef.current?.focus()} /> - {hasActionButton && action} +
(isTokenMode ? editableElementRef.current?.focus() : textareaRef.current?.focus())} + /> + {hasActionButton && actionButton}
)}
diff --git a/src/prompt-input/menus/menu-controller.ts b/src/prompt-input/menus/menu-controller.ts new file mode 100644 index 0000000000..b118963f71 --- /dev/null +++ b/src/prompt-input/menus/menu-controller.ts @@ -0,0 +1,151 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; + +import { filterOptions } from '../../autosuggest/utils/utils'; +import { OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; +import { generateTestIndexes } from '../../internal/components/options-list/utils/test-indexes'; +import { + HighlightedOptionHandlers, + HighlightedOptionState, + useHighlightedOption, +} from '../../internal/components/options-list/utils/use-highlight-option'; +import { PromptInputProps } from '../interfaces'; + +export type MenuItem = (OptionDefinition | OptionGroup) & { + type?: 'parent' | 'child'; + option: OptionDefinition | OptionGroup; +}; + +export interface UseMenuItemsProps { + menu: PromptInputProps.MenuDefinition; + filterText: string; + onSelectItem: (option: MenuItem) => void; +} + +export interface MenuItemsState extends HighlightedOptionState { + items: readonly MenuItem[]; + showAll: boolean; + getItemGroup: (item: MenuItem) => undefined | OptionGroup; +} + +export interface MenuItemsHandlers extends HighlightedOptionHandlers { + selectHighlightedOptionWithKeyboard(): boolean; + highlightVisibleOptionWithMouse(index: number): void; + selectVisibleOptionWithMouse(index: number): void; +} + +const isHighlightable = (option?: MenuItem) => { + return !!option && option.type !== 'parent'; +}; + +const isInteractive = (option?: MenuItem) => !!option && !option.disabled && option.type !== 'parent'; + +export const useMenuItems = ({ + menu, + filterText, + onSelectItem, +}: UseMenuItemsProps): [MenuItemsState, MenuItemsHandlers] => { + const { items, getItemGroup, getItemParent } = useMemo(() => createItems(menu.options), [menu.options]); + + const filteredItems = useMemo(() => { + const filteringType = menu.filteringType ?? 'auto'; + const filtered: MenuItem[] = + filteringType === 'auto' ? (filterOptions(items, filterText) as MenuItem[]) : [...items]; + generateTestIndexes(filtered, getItemParent); + return filtered; + }, [menu.filteringType, items, filterText, getItemParent]); + + const [highlightedOptionState, highlightedOptionHandlers] = useHighlightedOption({ + options: filteredItems, + isHighlightable, + }); + + const selectHighlightedOptionWithKeyboard = () => { + const { highlightedOption } = highlightedOptionState; + if (!highlightedOption || !isInteractive(highlightedOption)) { + return false; + } + onSelectItem(highlightedOption); + return true; + }; + + const highlightVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isHighlightable(item)) { + highlightedOptionHandlers.setHighlightedIndexWithMouse(index); + } + }; + + const selectVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isInteractive(item)) { + onSelectItem(item); + } + }; + + return [ + { ...highlightedOptionState, items: filteredItems, showAll: false, getItemGroup }, + { + ...highlightedOptionHandlers, + selectHighlightedOptionWithKeyboard, + highlightVisibleOptionWithMouse, + selectVisibleOptionWithMouse, + }, + ]; +}; + +function createItems(options: readonly OptionDefinition[]) { + const items: MenuItem[] = []; + const itemToGroup = new WeakMap(); + const getItemParent = (item: MenuItem) => itemToGroup.get(item); + const getItemGroup = (item: MenuItem) => getItemParent(item)?.option as OptionGroup; + + for (const option of options) { + if (isGroup(option)) { + for (const item of flattenGroup(option)) { + items.push(item); + } + } else { + items.push({ ...option, option }); + } + } + + function flattenGroup(group: OptionGroup) { + const { options, ...rest } = group; + + let hasOnlyDisabledChildren = true; + + const groupItem: MenuItem = { ...rest, type: 'parent', option: group }; + + const items: MenuItem[] = [groupItem]; + + for (const option of options) { + if (!option.disabled) { + hasOnlyDisabledChildren = false; + } + + const childOption: MenuItem = { + ...option, + type: 'child', + disabled: option.disabled || rest.disabled, + option, + }; + + items.push(childOption); + + itemToGroup.set(childOption, groupItem); + } + + items[0].disabled = items[0].disabled || hasOnlyDisabledChildren; + + return items; + } + + return { items, getItemGroup, getItemParent }; +} + +function isGroup(optionOrGroup: OptionDefinition): optionOrGroup is OptionGroup { + return 'options' in optionOrGroup; +} diff --git a/src/prompt-input/menus/menu-load-more-controller.ts b/src/prompt-input/menus/menu-load-more-controller.ts new file mode 100644 index 0000000000..b9cfdb9257 --- /dev/null +++ b/src/prompt-input/menus/menu-load-more-controller.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useRef } from 'react'; + +import { DropdownStatusProps } from '../../internal/components/dropdown-status/interfaces'; +import { PromptInputProps } from '../interfaces'; + +interface UseMenuLoadMoreProps { + menu: PromptInputProps.MenuDefinition; + statusType: DropdownStatusProps.StatusType; + onLoadItems: (detail: PromptInputProps.MenuLoadItemsDetail) => void; + onLoadMoreItems?: () => void; +} + +interface MenuLoadMoreHandlers { + fireLoadMoreOnScroll(): void; + fireLoadMoreOnRecoveryClick(): void; + fireLoadMoreOnMenuOpen(): void; + fireLoadMoreOnInputChange(filteringText: string): void; +} + +export const useMenuLoadMore = ({ + menu, + statusType, + onLoadItems, + onLoadMoreItems, +}: UseMenuLoadMoreProps): MenuLoadMoreHandlers => { + const lastFilteringText = useRef(null); + + const fireLoadMore = (firstPage: boolean, samePage: boolean, filteringText?: string) => { + if (filteringText !== undefined && filteringText !== lastFilteringText.current) { + lastFilteringText.current = filteringText; + } + + if (filteringText === undefined || lastFilteringText.current !== filteringText) { + onLoadItems({ + menuId: menu.id, + filteringText: lastFilteringText.current ?? '', + firstPage, + samePage, + }); + } + }; + + const fireLoadMoreOnScroll = () => { + if (menu.options.length > 0 && statusType === 'pending') { + onLoadMoreItems ? onLoadMoreItems() : fireLoadMore(false, false); + } + }; + + const fireLoadMoreOnRecoveryClick = () => fireLoadMore(false, true); + + const fireLoadMoreOnMenuOpen = () => fireLoadMore(true, false, lastFilteringText.current ?? ''); + + const fireLoadMoreOnInputChange = (filteringText: string) => fireLoadMore(true, false, filteringText); + + return { fireLoadMoreOnScroll, fireLoadMoreOnRecoveryClick, fireLoadMoreOnMenuOpen, fireLoadMoreOnInputChange }; +}; diff --git a/src/prompt-input/menus/menu-options-list.tsx b/src/prompt-input/menus/menu-options-list.tsx new file mode 100644 index 0000000000..d01b23c6f2 --- /dev/null +++ b/src/prompt-input/menus/menu-options-list.tsx @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import PlainList from '../../autosuggest/plain-list'; +import VirtualList from '../../autosuggest/virtual-list'; +import { useAnnouncement } from '../../select/utils/use-announcement'; +import { PromptInputProps } from '../interfaces'; +import { MenuItemsHandlers, MenuItemsState } from './menu-controller'; + +interface MenuOptionsListProps { + menu: PromptInputProps.MenuDefinition; + statusType: PromptInputProps.MenuDefinition['statusType']; + menuItemsState: MenuItemsState; + menuItemsHandlers: MenuItemsHandlers; + highlightedOptionId?: string; + highlightText: string; + listId: string; + controlId: string; + handleLoadMore: () => void; + hasDropdownStatus?: boolean; + listBottom?: React.ReactNode; + ariaDescribedby?: string; + selectedMenuItemAriaLabel?: string; + renderHighlightedMenuItemAriaLive?: PromptInputProps['renderHighlightedMenuItemAriaLive']; +} + +const createMouseEventHandler = (handler: (index: number) => void) => (itemIndex: number) => { + // prevent mouse events to avoid losing focus from the input + if (itemIndex > -1) { + handler(itemIndex); + } +}; + +export default function MenuOptionsList({ + menu, + statusType, + menuItemsState, + menuItemsHandlers, + highlightedOptionId, + highlightText, + listId, + controlId, + handleLoadMore, + hasDropdownStatus, + listBottom, + ariaDescribedby, + selectedMenuItemAriaLabel, + renderHighlightedMenuItemAriaLive, +}: MenuOptionsListProps) { + const handleMouseUp = createMouseEventHandler(menuItemsHandlers.selectVisibleOptionWithMouse); + const handleMouseMove = createMouseEventHandler(menuItemsHandlers.highlightVisibleOptionWithMouse); + + const ListComponent = menu.virtualScroll ? VirtualList : PlainList; + + const announcement = useAnnouncement({ + highlightText, + announceSelected: false, + highlightedOption: menuItemsState.highlightedOption, + getParent: option => menuItemsState.getItemGroup(option), + selectedAriaLabel: selectedMenuItemAriaLabel, + renderHighlightedAriaLive: renderHighlightedMenuItemAriaLive, + }); + + return ( + + ); +} diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index a0eefabaa1..d38f95996b 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -126,28 +126,31 @@ $invalid-border-offset: constants.$invalid-control-left-padding; .textarea { @include styles.styles-reset; - @include styles.control-border-radius-full(); @include styles.font-body-m; // Restore browsers' default resize values resize: none; // Restore default text cursor cursor: text; - // Allow multi-line placeholders + // Allow multi-line placeholders and word wrapping white-space: pre-wrap; - background-color: inherit; + word-wrap: break-word; + overflow-wrap: break-word; + background-color: transparent; padding-block: styles.$control-padding-vertical; padding-inline: styles.$control-padding-horizontal; color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-body-default); - max-inline-size: 100%; inline-size: 100%; display: block; box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; border: 0; - &::placeholder { + &.placeholder-visible::before { + content: attr(data-placeholder); @include styles.form-placeholder( $color: var(#{custom-props.$promptInputStylePlaceholderColor}, awsui.$color-text-input-placeholder), $font-size: var(#{custom-props.$promptInputStylePlaceholderFontSize}), @@ -155,6 +158,10 @@ $invalid-border-offset: constants.$invalid-control-left-padding; $font-weight: var(#{custom-props.$promptInputStylePlaceholderFontWeight}) ); opacity: 1; + pointer-events: none; + position: absolute; + inset-block-start: styles.$control-padding-vertical; + inset-inline-start: styles.$control-padding-horizontal; } &:hover { @@ -182,8 +189,11 @@ $invalid-border-offset: constants.$invalid-control-left-padding; padding-inline-start: $invalid-border-offset; } - &:disabled { + &:disabled, + &.textarea-disabled { color: var(#{custom-props.$promptInputStyleColorDisabled}, awsui.$color-text-input-disabled); + @include styles.form-disabled-element; + border: 0; cursor: default; &::placeholder { @@ -198,12 +208,23 @@ $invalid-border-offset: constants.$invalid-control-left-padding; var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-body-default) ); } + // Placeholder for disabled contentEditable div + &.placeholder-visible::before { + @include styles.form-placeholder-disabled; + opacity: 1; + } &-wrapper { display: flex; + position: relative; } } +.editable-wrapper { + flex: 1; + min-inline-size: 0; +} + .primary-action { align-self: flex-end; flex-shrink: 0; @@ -270,3 +291,8 @@ $invalid-border-offset: constants.$invalid-control-left-padding; align-self: stretch; cursor: text; } + +// Mode token spacing +.mode-token { + margin-inline-end: awsui.$space-xxs; +} diff --git a/src/prompt-input/test-classes/styles.scss b/src/prompt-input/test-classes/styles.scss index a395897823..f783c7ddc2 100644 --- a/src/prompt-input/test-classes/styles.scss +++ b/src/prompt-input/test-classes/styles.scss @@ -10,6 +10,10 @@ /* used in test-utils */ } +.content-editable { + /* used in test-utils - contentEditable element for token mode */ +} + .action-button { /* used in test-utils */ } diff --git a/src/prompt-input/tokens/token-utils.tsx b/src/prompt-input/tokens/token-utils.tsx new file mode 100644 index 0000000000..c0d860c4ef --- /dev/null +++ b/src/prompt-input/tokens/token-utils.tsx @@ -0,0 +1,215 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Token from '../../token/internal'; +import { PromptInputProps } from '../interfaces'; + +import styles from '../styles.css.js'; + +const TOKEN_DATA_PREFIX = 'data-token-'; +const TOKEN_TYPE_ATTRIBUTE = `${TOKEN_DATA_PREFIX}type`; + +/** + * Creates a DOM element for a token with data attributes. + */ +function createTokenContainerElement(type: string, attributes: Record): HTMLElement { + const container = document.createElement('span'); + container.style.display = 'inline'; + container.contentEditable = 'false'; + container.setAttribute(TOKEN_TYPE_ATTRIBUTE, type); + + Object.entries(attributes).forEach(([key, value]) => { + container.setAttribute(`${TOKEN_DATA_PREFIX}${key}`, value); + }); + + return container; +} + +/** + * Token renderer factory for different token types. + */ +const tokenRenderers: Record< + PromptInputProps.InputToken['type'], + (token: PromptInputProps.InputToken, target: HTMLElement, containers: Set) => void +> = { + text: (token, target) => { + if (token.type === 'text' && token.value) { + target.appendChild(document.createTextNode(token.value)); + } + }, + reference: (token, target, containers) => { + if (token.type === 'reference') { + const container = createTokenContainerElement('reference', { + id: token.id, + value: token.value, + }); + target.appendChild(container); + containers.add(container); + ReactDOM.render(, container); + } + }, +}; + +/** + * Renders a mode token into a DOM element. + */ +function renderModeToken(mode: PromptInputProps.ModeToken, target: HTMLElement, containers: Set): void { + const container = createTokenContainerElement('mode', { + id: mode.id, + value: mode.value, + }); + target.appendChild(container); + containers.add(container); + ReactDOM.render( + , + container + ); +} + +/** + * Cleans up React components and DOM content from the target element. + */ +function cleanupDOM(targetElement: HTMLElement, reactContainers: Set): void { + reactContainers.forEach(container => { + try { + ReactDOM.unmountComponentAtNode(container); + } catch (error) { + console.warn('Failed to unmount React component:', error); + } + }); + reactContainers.clear(); + targetElement.innerHTML = ''; +} + +/** + * Ensures the contentEditable element can receive cursor at the end. + * Adds an empty text node if the last child is an element node. + */ +function ensureCursorPlacement(targetElement: HTMLElement): void { + if (targetElement.lastChild?.nodeType === Node.ELEMENT_NODE) { + targetElement.appendChild(document.createTextNode('')); + } +} + +/** + * Renders an array of tokens into a contentEditable element. + * Handles both text tokens (as text nodes) and reference tokens (as React components). + * Mode token is rendered separately at the beginning if provided. + */ +export function renderTokensToDOM( + tokens: readonly PromptInputProps.InputToken[], + mode: PromptInputProps.ModeToken | undefined, + targetElement: HTMLElement, + reactContainers: Set +): void { + if (!targetElement || !(targetElement instanceof HTMLElement)) { + throw new Error('Invalid target element provided to renderTokensToDOM'); + } + + cleanupDOM(targetElement, reactContainers); + + // Render mode token first if present + if (mode) { + renderModeToken(mode, targetElement, reactContainers); + } + + // Render regular tokens + tokens.forEach(token => { + const renderer = tokenRenderers[token.type]; + if (renderer) { + renderer(token, targetElement, reactContainers); + } else { + console.warn(`Unknown token type: ${token.type}`); + } + }); + + ensureCursorPlacement(targetElement); +} + +/** + * Extracts all data-token-* attributes from an element. + */ +function extractTokenData(element: HTMLElement): Record { + return Array.from(element.attributes) + .filter(attr => attr.name.startsWith(TOKEN_DATA_PREFIX)) + .reduce( + (acc, attr) => { + const key = attr.name.replace(TOKEN_DATA_PREFIX, ''); + acc[key] = attr.value; + return acc; + }, + {} as Record + ); +} + +/** + * Token extractor factory for different token types. + */ +const tokenExtractors: Record< + string, + (element: HTMLElement, flushText: () => void) => PromptInputProps.InputToken | null +> = { + reference: (element, flushText) => { + flushText(); + const data = extractTokenData(element); + return { + type: 'reference', + id: data.id || '', + label: element.textContent || '', + value: data.value || element.textContent || '', + }; + }, +}; + +/** + * Extracts an array of tokens from a contentEditable DOM element. + * Converts text nodes to TextInputToken and token elements to their respective types. + */ +export function domToTokenArray(element: HTMLElement): PromptInputProps.InputToken[] { + if (!element || !(element instanceof HTMLElement)) { + throw new Error('Invalid element provided to domToTokenArray'); + } + + const tokens: PromptInputProps.InputToken[] = []; + let textBuffer = ''; + + const flushTextBuffer = (): void => { + if (textBuffer) { + tokens.push({ type: 'text', value: textBuffer }); + textBuffer = ''; + } + }; + + const processNode = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + textBuffer += node.textContent || ''; + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const tokenType = el.getAttribute(TOKEN_TYPE_ATTRIBUTE); + + if (tokenType === 'mode') { + // Skip mode tokens - they're handled separately via the mode prop + flushTextBuffer(); + } else if (tokenType && tokenExtractors[tokenType]) { + const token = tokenExtractors[tokenType](el, flushTextBuffer); + if (token) { + tokens.push(token); + } + } else { + // Recursively process children for non-token elements + Array.from(node.childNodes).forEach(processNode); + } + } + }; + + Array.from(element.childNodes).forEach(processNode); + flushTextBuffer(); + + return tokens; +} + +export function getPromptText(tokens: readonly PromptInputProps.InputToken[]): string { + return tokens.map(token => token.value).join(''); +} diff --git a/src/prompt-input/tokens/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts new file mode 100644 index 0000000000..3b8c197132 --- /dev/null +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -0,0 +1,222 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { PromptInputProps } from '../interfaces'; +import { createCursorManager } from '../utils/cursor-utils'; +import { domToTokenArray, getPromptText, renderTokensToDOM } from './token-utils'; + +interface UseEditableOptions { + elementRef: React.RefObject; + reactContainersRef: React.MutableRefObject>; + tokens?: readonly PromptInputProps.InputToken[]; + mode?: PromptInputProps.ModeToken; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + onModeRemoved?: () => void; + adjustInputHeight: () => void; + disabled?: boolean; + // Optional cursor position to set when tokens change + cursorPosition?: number | null; +} + +// Helper to compare token arrays for equality +function tokensEqual( + a: readonly PromptInputProps.InputToken[] | undefined, + b: readonly PromptInputProps.InputToken[] | undefined +): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + const tokenA = a[i]; + const tokenB = b[i]; + + if (tokenA.type !== tokenB.type || tokenA.value !== tokenB.value) { + return false; + } + + if (tokenA.type === 'reference' && tokenB.type === 'reference') { + if (tokenA.id !== tokenB.id || tokenA.label !== tokenB.label) { + return false; + } + } + } + + return true; +} + +interface UseEditableReturn { + // Input event handler + handleInput: () => void; +} + +/** + * Custom hook for managing contentEditable elements with token support. + * Follows the use-editable package pattern - focuses on DOM synchronization + * and cursor management while leaving state management to the parent component. + */ +export function useEditableTokens({ + elementRef, + reactContainersRef, + tokens, + mode, + tokensToText, + onChange, + onModeRemoved, + adjustInputHeight, + disabled = false, + cursorPosition = null, +}: UseEditableOptions): UseEditableReturn { + const lastRenderedTokensRef = useRef(undefined); + const lastCursorPositionRef = useRef(null); + + // Create cursor manager instance + const cursorManager = useMemo( + () => (elementRef.current ? createCursorManager(elementRef.current) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [elementRef.current] + ); + + // Cursor position utilities using the cursor manager + const getCursorPosition = useCallback((): number => { + if (!cursorManager) { + return 0; + } + return cursorManager.getPosition(); + }, [cursorManager]); + + const setCursorPosition = useCallback( + (position: number) => { + if (disabled || !cursorManager) { + return; + } + cursorManager.setPosition(position); + }, + [disabled, cursorManager] + ); + + // Handle input events directly - simpler than MutationObserver + const handleInput = useCallback(() => { + if (!elementRef.current) { + return; + } + + // Extract tokens from DOM + const extractedTokens = domToTokenArray(elementRef.current); + + // Check if mode element still exists in DOM + const modeElement = elementRef.current.querySelector('[data-token-type="mode"]'); + const currentMode = modeElement ? true : false; + + // Check if mode was removed + if (mode && !currentMode && onModeRemoved) { + onModeRemoved(); + } + + // Notify parent component of changes + const value = tokensToText ? tokensToText(extractedTokens) : getPromptText(extractedTokens); + onChange({ + value, + tokens: extractedTokens, + }); + + // Update last rendered tokens + lastRenderedTokensRef.current = extractedTokens; + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, onModeRemoved, onChange, adjustInputHeight, tokensToText]); + + // Sync React props to DOM (like a controlled component) + // This is the ONLY place that updates the DOM - tokens prop is the source of truth + const lastRenderedModeRef = useRef(undefined); + + useEffect(() => { + if (disabled || !elementRef.current) { + return; + } + + // Only update DOM if tokens actually changed (avoid re-rendering on every state update) + // Use deep comparison to avoid rebuilding DOM when tokens content is the same + const tokensChanged = !tokensEqual(lastRenderedTokensRef.current, tokens); + + // Check if mode changed + const modeChanged = lastRenderedModeRef.current !== mode; + + // Only consider cursor changed if it's explicitly set (not null) + // null means "preserve current cursor" which shouldn't trigger DOM rebuild + const explicitCursorChange = cursorPosition !== null && lastCursorPositionRef.current !== cursorPosition; + + // Skip DOM rebuild if nothing changed + if (!tokensChanged && !modeChanged && !explicitCursorChange) { + // Still update refs even when skipping rebuild + lastRenderedTokensRef.current = tokens; + lastRenderedModeRef.current = mode; + lastCursorPositionRef.current = cursorPosition; + return; + } + + // Update refs before rebuilding + lastRenderedTokensRef.current = tokens; + lastRenderedModeRef.current = mode; + lastCursorPositionRef.current = cursorPosition; + + // Save current cursor position BEFORE any DOM changes (for normal typing) + const savedCursorPosition = getCursorPosition(); + + // Render tokens to DOM - this clears and rebuilds the entire DOM + const hasContent = mode || (tokens && tokens.length > 0); + if (hasContent) { + renderTokensToDOM(tokens ?? [], mode, elementRef.current, reactContainersRef.current); + } else { + // Clear DOM if no tokens + elementRef.current.innerHTML = ''; + } + + // Restore cursor position after DOM update + requestAnimationFrame(() => { + if (!elementRef.current || !hasContent) { + return; + } + + // Use explicit cursor position if provided (from menu selection, etc.) + // Otherwise restore the saved position (from normal typing) + const positionToSet = cursorPosition !== null ? cursorPosition : savedCursorPosition; + + // Focus BEFORE setting cursor position + elementRef.current.focus(); + + // Try to set cursor position + try { + setCursorPosition(positionToSet); + } catch { + // If cursor positioning fails (e.g., position beyond text nodes), + // fall back to positioning at the end + const range = document.createRange(); + range.selectNodeContents(elementRef.current); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }); + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disabled, tokens, mode, cursorPosition, adjustInputHeight]); + + return { + handleInput, + }; +} + +// Export the interface for the cursor position callback +export type SetCursorPositionCallback = (position: number | null) => void; diff --git a/src/prompt-input/utils/cursor-utils.ts b/src/prompt-input/utils/cursor-utils.ts new file mode 100644 index 0000000000..7b513fe588 --- /dev/null +++ b/src/prompt-input/utils/cursor-utils.ts @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Utility class for managing cursor position and selection in contentEditable elements. + * Handles the complexity of DOM tree walking and range manipulation. + */ +export class CursorManager { + constructor(private element: HTMLElement) {} + + /** + * Gets the current cursor position as a character offset from the start of the element. + */ + getPosition(): number { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return 0; + } + + const range = selection.getRangeAt(0); + const untilRange = document.createRange(); + untilRange.setStart(this.element, 0); + untilRange.setEnd(range.startContainer, range.startOffset); + + return untilRange.toString().length; + } + + /** + * Sets the cursor position to a specific character offset. + */ + setPosition(position: number): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const location = this.walkToPosition(position); + + if (!location) { + // Position is beyond content, set cursor at end + const range = document.createRange(); + range.selectNodeContents(this.element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + return; + } + + const range = document.createRange(); + range.setStart(location.node, location.offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + + /** + * Sets a selection range from start to end positions. + */ + setRange(start: number, end: number): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const startLocation = this.walkToPosition(start); + const endLocation = this.walkToPosition(end); + + if (!startLocation || !endLocation) { + return; + } + + const range = document.createRange(); + range.setStart(startLocation.node, startLocation.offset); + range.setEnd(endLocation.node, endLocation.offset); + selection.removeAllRanges(); + selection.addRange(range); + } + + /** + * Walks the DOM tree to find the node and offset for a given character position. + * Only counts text nodes that are NOT inside contentEditable=false elements. + */ + private walkToPosition(position: number): { node: Node; offset: number } | null { + const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT, { + acceptNode: (node: Node) => { + // Check if this text node is inside a contentEditable=false element + let parent = node.parentElement; + while (parent && parent !== this.element) { + if (parent.contentEditable === 'false') { + return NodeFilter.FILTER_REJECT; + } + parent = parent.parentElement; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let currentPos = 0; + let node: Node | null; + + while ((node = walker.nextNode())) { + const textLength = node.textContent?.length || 0; + if (currentPos + textLength >= position) { + const offset = position - currentPos; + return { node, offset: Math.min(offset, textLength) }; + } + currentPos += textLength; + } + + return null; + } +} + +/** + * Creates a CursorManager instance for the given element. + */ +export function createCursorManager(element: HTMLElement): CursorManager { + return new CursorManager(element); +} diff --git a/src/prompt-input/utils/keyboard-handlers.ts b/src/prompt-input/utils/keyboard-handlers.ts new file mode 100644 index 0000000000..eb77ba2d41 --- /dev/null +++ b/src/prompt-input/utils/keyboard-handlers.ts @@ -0,0 +1,139 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { MenuItemsHandlers, MenuItemsState } from '../menus/menu-controller'; + +export interface KeyboardHandlerDeps { + menuOpen: boolean; + menuItemsState: MenuItemsState | null; + menuItemsHandlers: MenuItemsHandlers | null; + onAction?: (detail: PromptInputProps.ActionDetail) => void; + onModeRemoved?: () => void; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + tokens?: readonly PromptInputProps.InputToken[]; + getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string; + deleteTokenAtCursor: () => boolean; + closeMenu: () => void; +} + +/** + * Creates keyboard event handlers for contentEditable prompt input. + * Handles menu navigation, token deletion, and form submission. + */ +export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { + /** + * Handles menu navigation keys (ArrowUp, ArrowDown, Enter, Escape). + * @returns true if the event was handled, false otherwise + */ + function handleMenuNavigation(event: React.KeyboardEvent): boolean { + if (!deps.menuOpen || !deps.menuItemsHandlers || !deps.menuItemsState) { + return false; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (deps.menuItemsState.items.length - 1 === deps.menuItemsState.highlightedIndex) { + deps.menuItemsHandlers.goHomeWithKeyboard(); + } else { + deps.menuItemsHandlers.moveHighlightWithKeyboard(1); + } + return true; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + if ( + (deps.menuItemsState.highlightedOption?.type === 'child' && deps.menuItemsState.highlightedIndex === 1) || + deps.menuItemsState.highlightedIndex === 0 + ) { + deps.menuItemsHandlers.goEndWithKeyboard(); + } else { + deps.menuItemsHandlers.moveHighlightWithKeyboard(-1); + } + return true; + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (deps.menuItemsHandlers.selectHighlightedOptionWithKeyboard()) { + return true; + } + } + + if (event.key === 'Escape') { + event.preventDefault(); + deps.closeMenu(); + return true; + } + + return false; + } + + /** + * Handles Enter key for form submission and action triggering. + */ + function handleEnterKey(event: React.KeyboardEvent): void { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) { + return; + } + + const form = (event.currentTarget as HTMLElement).closest('form'); + if (form && !event.isDefaultPrevented()) { + form.requestSubmit(); + } + event.preventDefault(); + + const plainText = deps.tokensToText ? deps.tokensToText(deps.tokens ?? []) : deps.getPromptText(deps.tokens ?? []); + + if (deps.onAction) { + deps.onAction({ value: plainText, tokens: [...(deps.tokens ?? [])] }); + } + } + + /** + * Handles Backspace key for token deletion. + */ + function handleBackspaceKey(event: React.KeyboardEvent): boolean { + if (event.key !== 'Backspace') { + return false; + } + + const deleted = deps.deleteTokenAtCursor(); + if (deleted) { + event.preventDefault(); + return true; + } + + return false; + } + + /** + * Handles mode token deletion via Backspace. + */ + function handleModeBackspace(event: React.KeyboardEvent, nodeToCheck: Node | null): boolean { + if (event.key !== 'Backspace') { + return false; + } + + if (nodeToCheck?.nodeType === Node.ELEMENT_NODE) { + const element = nodeToCheck as Element; + const tokenType = element.getAttribute('data-token-type'); + + if (tokenType === 'mode' && deps.onModeRemoved) { + event.preventDefault(); + deps.onModeRemoved(); + return true; + } + } + + return false; + } + + return { + handleMenuNavigation, + handleEnterKey, + handleBackspaceKey, + handleModeBackspace, + }; +} diff --git a/src/test-utils/dom/prompt-input/index.ts b/src/test-utils/dom/prompt-input/index.ts index a241b1f79a..cdbcd4c8ec 100644 --- a/src/test-utils/dom/prompt-input/index.ts +++ b/src/test-utils/dom/prompt-input/index.ts @@ -1,18 +1,78 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComponentWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import { ComponentWrapper, createWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import { escapeSelector } from '@cloudscape-design/test-utils-core/utils'; import { act, setNativeValue } from '@cloudscape-design/test-utils-core/utils-dom'; +import OptionWrapper from '../internal/option'; + +import dropdownStyles from '../../../internal/components/dropdown/styles.selectors.js'; +import selectableStyles from '../../../internal/components/selectable-item/styles.selectors.js'; import testutilStyles from '../../../prompt-input/test-classes/styles.selectors.js'; +export class PromptInputMenuWrapper extends ComponentWrapper { + findOptions(): Array { + return this.findAll(`.${selectableStyles['selectable-item']}[data-test-index]`).map( + (elementWrapper: ElementWrapper) => new OptionWrapper(elementWrapper.getElement()) + ); + } + + /** + * Returns an option from the menu. + * + * @param optionIndex 1-based index of the option to select. + */ + findOption(optionIndex: number): OptionWrapper | null { + return this.findComponent( + `.${selectableStyles['selectable-item']}[data-test-index="${optionIndex}"]`, + OptionWrapper + ); + } + + /** + * Returns an option from the menu by its value + * + * @param value The 'value' of the option. + */ + findOptionByValue(value: string): OptionWrapper | null { + const toReplace = escapeSelector(value); + return this.findComponent(`.${OptionWrapper.rootSelector}[data-value="${toReplace}"]`, OptionWrapper); + } + + findOpenMenu(): ElementWrapper | null { + return this.find(`.${dropdownStyles.dropdown}[data-open=true]`); + } +} + +class PortalPromptInputMenuWrapper extends PromptInputMenuWrapper { + findOpenMenu(): ElementWrapper | null { + return createWrapper().find(`.${dropdownStyles.dropdown}[data-open=true]`); + } +} + export default class PromptInputWrapper extends ComponentWrapper { static rootSelector = testutilStyles.root; + /** + * Finds the native textarea element. + * + * Note: When menus are defined, the component uses a contentEditable element instead of a textarea. + * In this case, this method may fail to find the textarea element. Use findContentEditableElement() + * or the getValue()/setValue() methods instead. + */ findNativeTextarea(): ElementWrapper { return this.findByClassName(testutilStyles.textarea)!; } + /** + * Finds the contentEditable element used when menus are defined. + * Returns null if the component does not have menus defined. + */ + findContentEditableElement(): ElementWrapper | null { + return this.find('[contenteditable="true"]'); + } + /** * Finds the action button. Note that, despite its typings, this may return null. */ @@ -35,26 +95,124 @@ export default class PromptInputWrapper extends ComponentWrapper { return this.findByClassName(testutilStyles['primary-action']); } + /** + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + findMenu(options = { expandMenusToViewport: false }): PromptInputMenuWrapper { + return options.expandMenusToViewport + ? createWrapper().findComponent(`.${dropdownStyles.dropdown}[data-open=true]`, PortalPromptInputMenuWrapper)! + : new PromptInputMenuWrapper(this.getElement()); + } + /** + * Gets the value of the component. + * + * Returns the current value of the textarea (when no menus are defined) or the text content of the contentEditable element (when menus are defined). + */ + @usesDom getValue(): string { + const contentEditable = this.findContentEditableElement(); + if (contentEditable) { + return contentEditable.getElement().textContent || ''; + } + const textarea = this.findNativeTextarea(); + return textarea ? textarea.getElement().value : ''; + } + + /** + * Sets the value of the component by directly setting text content. + * This does NOT trigger menu detection. Use the component ref's insertText() method + * to simulate typing and trigger menus. + * + * @param value String value to set the component to. + */ + @usesDom setValue(value: string): void { + const contentEditable = this.findContentEditableElement(); + if (contentEditable) { + const element = contentEditable.getElement(); + act(() => { + element.textContent = value; + element.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); + }); + } else { + this.setTextareaValue(value); + } + } + + /** + * @deprecated Use getValue() instead. + * * Gets the value of the component. * * Returns the current value of the textarea. */ @usesDom getTextareaValue(): string { - return this.findNativeTextarea().getElement().value; + return this.getValue(); } /** + * @deprecated Use setValue() instead. + * * Sets the value of the component and calls the onChange handler. * * @param value value to set the textarea to. */ @usesDom setTextareaValue(value: string): void { - const element = this.findNativeTextarea().getElement(); + const textarea = this.findNativeTextarea(); + if (textarea) { + const element = textarea.getElement(); + act(() => { + const event = new Event('change', { bubbles: true, cancelable: false }); + setNativeValue(element, value); + element.dispatchEvent(event); + }); + } + } + + /** + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + @usesDom + isMenuOpen(options = { expandMenusToViewport: false }): boolean { + return this.findMenu(options).findOpenMenu() !== null; + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param value value of option to select + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + @usesDom + selectMenuOptionByValue(value: string, options = { expandMenusToViewport: false }): void { + act(() => { + const menu = this.findMenu(options); + const option = menu.findOptionByValue(value); + if (!option) { + throw new Error(`Option with value "${value}" not found in menu`); + } + option.fireEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param optionIndex 1-based index of the option to select + * @param options + * * expandMenusToViewport (boolean) - Use this when the component under test is rendered with an `expandMenusToViewport` flag. + */ + @usesDom + selectMenuOption(optionIndex: number, options = { expandMenusToViewport: false }): void { act(() => { - const event = new Event('change', { bubbles: true, cancelable: false }); - setNativeValue(element, value); - element.dispatchEvent(event); + const menu = this.findMenu(options); + const option = menu.findOption(optionIndex); + if (!option) { + throw new Error(`Option at index ${optionIndex} not found in menu`); + } + option.fireEvent(new MouseEvent('mouseup', { bubbles: true })); }); } } diff --git a/src/token/internal.tsx b/src/token/internal.tsx index a94bd4cb68..6d906d0795 100644 --- a/src/token/internal.tsx +++ b/src/token/internal.tsx @@ -23,6 +23,7 @@ type InternalTokenProps = TokenProps & InternalBaseComponentProps & { role?: string; disableInnerPadding?: boolean; + value?: string; }; function InternalToken({ @@ -43,6 +44,7 @@ function InternalToken({ // Internal role, disableInnerPadding, + value, // Base __internalRootRef, @@ -126,6 +128,7 @@ function InternalToken({ setShowTooltip(false); }} tabIndex={!!tooltipContent && isInline && isEllipsisActive ? 0 : undefined} + data-token-value={value} >