diff --git a/package-lock.json b/package-lock.json index 4a1eea1340..78d883f421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -818,6 +818,7 @@ "version": "7.27.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1271,6 +1272,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1292,6 +1294,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1339,6 +1342,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", @@ -2453,7 +2457,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" } @@ -2517,7 +2520,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" } @@ -3636,6 +3638,7 @@ "integrity": "sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^4.0.3", @@ -4484,6 +4487,7 @@ "version": "9.6.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4685,6 +4689,7 @@ "version": "16.14.34", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4867,6 +4872,7 @@ "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", @@ -4896,6 +4902,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5146,7 +5153,6 @@ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -5160,7 +5166,6 @@ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", @@ -5175,8 +5180,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.24.0", @@ -5251,6 +5255,7 @@ "integrity": "sha512-OmwPKV8c5ecLqo+EkytN7oUeYfNmRI4uOXGIR1ybP7AK5Zz+l9R0dGfoadEuwi1aZXAL0vwuhtq3p0OL3dfqHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.20.0" }, @@ -5273,6 +5278,7 @@ "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.1.2", "loglevel": "^1.6.0", @@ -5590,6 +5596,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5655,6 +5662,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6222,6 +6230,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -6340,22 +6349,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, "node_modules/bare-fs": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", @@ -6644,7 +6637,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7747,6 +7740,7 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -8362,7 +8356,6 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -9361,6 +9354,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9421,6 +9415,7 @@ "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10017,7 +10012,6 @@ "integrity": "sha512-Bkoqs+39fHwjos51qab7ZWmvZrYNBbzgSAIykH2CrgLOLhHJXzC30DP9lZq2MsmaUsbBnN5c5m8VqAhOHTrCRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/snapshot": "^4.0.16", "deep-eql": "^5.0.2", @@ -10050,7 +10044,6 @@ "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0" }, @@ -10064,7 +10057,6 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -10078,7 +10070,6 @@ "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -10097,8 +10088,7 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-webdriverio/node_modules/chalk": { "version": "4.1.2", @@ -10106,7 +10096,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10130,7 +10119,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10141,7 +10129,6 @@ "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", @@ -10160,7 +10147,6 @@ "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", @@ -10177,7 +10163,6 @@ "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", @@ -10194,7 +10179,6 @@ "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", @@ -10216,7 +10200,6 @@ "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -10232,7 +10215,6 @@ "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -10251,7 +10233,6 @@ "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -10267,7 +10248,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12913,6 +12893,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -13269,6 +13250,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -13872,6 +13854,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -16478,6 +16461,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17200,6 +17184,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17631,6 +17616,7 @@ "node_modules/react": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -17643,6 +17629,7 @@ "node_modules/react-dom": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -18317,6 +18304,7 @@ "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -19756,6 +19744,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -20508,7 +20497,6 @@ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -20741,7 +20729,8 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -20889,6 +20878,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21402,6 +21392,7 @@ "integrity": "sha512-LTJt6Z/iDM0ne/4ytd3BykoPv9CuJ+CAILOzlwFeMGn4Mj02i4Bk2Rg9o/jeJ89f52hnv4OPmNjD0e8nzWAy5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", @@ -21455,6 +21446,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21502,6 +21494,7 @@ "version": "5.1.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx new file mode 100644 index 0000000000..057b445ae1 --- /dev/null +++ b/pages/prompt-input/shortcuts.page.tsx @@ -0,0 +1,663 @@ +// 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, + KeyValuePairs, + PromptInput, + PromptInputProps, + SpaceBetween, +} from '~components'; +import { OptionDefinition, OptionGroup } from '~components/internal/components/option/interfaces'; + +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 firstNames = [ + 'John', + 'Jane', + 'Bob', + 'Alice', + 'Charlie', + 'Diana', + 'Evan', + 'Fiona', + 'George', + 'Hannah', + 'Ian', + 'Julia', + 'Kevin', + 'Laura', + 'Michael', + 'Nina', + 'Oliver', + 'Patricia', + 'Quinn', + 'Rachel', + 'Samuel', + 'Teresa', + 'Uma', + 'Victor', + 'Wendy', + 'Xavier', + 'Yara', + 'Zachary', +]; +const lastNames = [ + 'Smith', + 'Johnson', + 'Williams', + 'Brown', + 'Jones', + 'Garcia', + 'Miller', + 'Davis', + 'Rodriguez', + 'Martinez', + 'Hernandez', + 'Lopez', + 'Gonzalez', + 'Wilson', + 'Anderson', + 'Thomas', + 'Taylor', + 'Moore', + 'Jackson', + 'Martin', + 'Lee', + 'Perez', + 'Thompson', + 'White', + 'Harris', + 'Sanchez', + 'Clark', + 'Ramirez', + 'Lewis', + 'Robinson', +]; +const roles = [ + 'Software Engineer', + 'Senior Software Engineer', + 'Staff Software Engineer', + 'Principal Engineer', + 'Engineering Manager', + 'Senior Engineering Manager', + 'Product Manager', + 'Senior Product Manager', + 'Designer', + 'Senior Designer', + 'UX Researcher', + 'Data Scientist', + 'Senior Data Scientist', + 'DevOps Engineer', + 'Security Engineer', + 'QA Engineer', + 'Technical Writer', + 'Solutions Architect', +]; +const teams = [ + 'Backend Services', + 'Frontend Platform', + 'AI/ML Products', + 'Customer Experience', + 'Design Systems', + 'Component Library', + 'Infrastructure', + 'DevOps', + 'Application Security', + 'Data Platform', + 'Analytics', + 'Mobile Apps', + 'API Gateway', + 'Cloud Services', +]; + +// Generate 50 realistic user options +const mentionOptions: OptionDefinition[] = Array.from({ length: 50 }, (_, i) => { + const firstName = firstNames[i % firstNames.length]; + const lastName = lastNames[Math.floor(i / firstNames.length) % lastNames.length]; + const role = roles[i % roles.length]; + const team = teams[i % teams.length]; + + return { + value: `${firstName.toLowerCase()}.${lastName.toLowerCase()}.${i}`, + label: `${firstName} ${lastName}`, + description: `${role} - ${team}`, + iconName: 'user-profile', + }; +}); + +const commandOptions: OptionDefinition[] = [ + { 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: (OptionDefinition | OptionGroup)[] = [ + { value: 'aws', label: 'AWS', description: 'Amazon Web Services' }, + { + label: 'Cloudscape', + options: [ + { value: 'components', label: 'Components', description: 'UI components' }, + { value: 'design-tokens', label: 'Design Tokens', description: 'Design system tokens' }, + ], + }, + { 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 [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: PromptInputProps.MenuDefinition[] = [ + { + id: 'mentions', + trigger: '@', + options: mentionOptions, + filteringType: 'auto', + empty: 'No mentions found', + }, + { + id: 'mode', + trigger: '/', + options: commandOptions, + filteringType: 'auto', + useAtStart: true, + empty: 'No commands found', + }, + { + id: 'topics', + trigger: '#', + options: topicOptions, + filteringType: 'auto', + empty: 'No topics found', + }, + ]; + + 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 && enableAutoFocus) { + 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 || tokens.length > 0 ? ( + {extractedText}, + }, + ] + : []), + ...(tokens.length > 0 + ? [ + { + label: 'Current tokens', + value: {JSON.stringify(tokens, null, 2)}, + }, + ] + : []), + ]} + /> + ) : null} + +
{ + 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' }} + > + { + setTokens(event.detail.tokens ?? []); + setPlainTextValue(event.detail.value ?? ''); + }} + onAction={({ detail }) => { + setExtractedText(detail.value ?? ''); + + // Keep mode token (first pinned reference from useAtStart menu) after submission + const modeToken = detail.tokens?.find( + (token): token is PromptInputProps.ReferenceToken => + token.type === 'reference' && token.pinned === true + ); + + setTokens(modeToken ? [modeToken] : []); + 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); + // Modes are now just reference tokens - no special handling needed + }} + i18nStrings={ + { + selectedMenuItemAriaLabel: 'Selected', + menuErrorIconAriaLabel: 'Error', + menuRecoveryText: 'Retry', + tokenInsertedAriaLabel: (token: { label?: string; value: string }) => + `${token.label || token.value} inserted`, + tokenPinnedAriaLabel: (token: { label?: string; value: string }) => + `${token.label || token.value} pinned`, + tokenRemovedAriaLabel: (token: { label?: string; value: string }) => + `${token.label || token.value} removed`, + } as PromptInputProps['i18nStrings'] + } + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + + detail.id.includes('files') && setFiles(detail.files)} + onItemClick={({ detail }) => { + if (detail.id === 'slash') { + // Filter out only pinned references to check content after them + const nonPinnedTokens = tokens.filter( + token => !(token.type === 'reference' && token.pinned) + ); + + // Determine if we need to add space before slash + let needsSpace = false; + if (nonPinnedTokens.length > 0) { + const firstToken = nonPinnedTokens[0]; + needsSpace = firstToken.type !== 'text' || !firstToken.value.startsWith(' '); + } + + ref.current?.insertText(needsSpace ? '/ ' : '/', 0, needsSpace ? 1 : undefined); + } + if (detail.id === 'at') { + ref.current?.insertText('@'); + } + }} + 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, + }, + { + type: 'icon-button', + id: 'slash', + iconName: 'slash', + text: 'Insert slash', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'at', + iconName: 'at-symbol', + text: 'Insert at symbol', + disabled: isDisabled || isReadOnly, + }, + ]} + variant="icon" + /> + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> + +
+ + + +
+ } + /> + ); +} diff --git a/pages/webpack.config.base.cjs b/pages/webpack.config.base.cjs index f359318eb4..910db2c592 100644 --- a/pages/webpack.config.base.cjs +++ b/pages/webpack.config.base.cjs @@ -48,6 +48,7 @@ module.exports = ({ react: 'react18', 'react-dom': 'react-dom18', 'react-dom/client': 'react-dom18/client', + [path.resolve(componentsPath, 'internal/vendor/react-dom-client-stub.js')]: 'react-dom18/client', } : { '~mount': path.resolve(__dirname, './app/mount/react16.ts'), diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b7c0e3b169..b0595953d6 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -19541,10 +19541,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, @@ -19553,7 +19560,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ActionDetail", "name": "onAction", }, { @@ -19564,10 +19571,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, @@ -19576,7 +19590,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ChangeDetail", "name": "onChange", }, { @@ -19682,6 +19696,332 @@ 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. + +Requires React 18.", + "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. + +Requires React 18.", + "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", + "edit-gen-ai", + "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", + "search-gen-ai", + "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", + "suggestions-gen-ai", + "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\` (pagination). +- 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 to use to fetch options (undefined for pagination). +- \`firstPage\` - Indicates that you should fetch the first page of options. +- \`samePage\` - Indicates that you should fetch the same page (for example, when clicking recovery button). + +Requires React 18.", + "detailInlineType": { + "name": "PromptInputProps.MenuLoadItemsDetail", + "properties": [ + { + "name": "filteringText", + "optional": true, + "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", + }, ], "functions": [ { @@ -19690,6 +20030,25 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "parameters": [], "returnType": "void", }, + { + "description": "Inserts text at a specified position. Triggers input events and menu detection when \`menus\` is defined.", + "name": "insertText", + "parameters": [ + { + "name": "text", + "type": "string", + }, + { + "name": "cursorStart", + "type": "number", + }, + { + "name": "cursorEnd", + "type": "number", + }, + ], + "returnType": "void", + }, { "description": "Selects all text in the textarea control.", "name": "select", @@ -19723,6 +20082,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", @@ -19936,7 +20296,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", @@ -20012,7 +20374,107 @@ receive focus.", "description": "Determines whether the secondary content area of the input has padding. If true, removes the default padding from the secondary content area.", "name": "disableSecondaryContentPaddings", "optional": true, - "type": "boolean", + "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": "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", + }, + { + "inlineType": { + "name": "(token: { label?: string | undefined; value: string; }) => string", + "parameters": [ + { + "name": "token", + "type": "{ label?: string | undefined; value: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenInsertedAriaLabel", + "optional": true, + "type": "((token: { label?: string | undefined; value: string; }) => string)", + }, + { + "inlineType": { + "name": "(token: { label?: string | undefined; value: string; }) => string", + "parameters": [ + { + "name": "token", + "type": "{ label?: string | undefined; value: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenPinnedAriaLabel", + "optional": true, + "type": "((token: { label?: string | undefined; value: string; }) => string)", + }, + { + "inlineType": { + "name": "(token: { label?: string | undefined; value: string; }) => string", + "parameters": [ + { + "name": "token", + "type": "{ label?: string | undefined; value: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenRemovedAriaLabel", + "optional": true, + "type": "((token: { label?: string | undefined; value: string; }) => 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, @@ -20033,6 +20495,15 @@ single form field.", "optional": true, "type": "boolean", }, + { + "description": "Maximum height of the menu dropdown in pixels. +When not specified, the menu will grow to fit its content. + +Requires React 18.", + "name": "maxMenuHeight", + "optional": true, + "type": "number", + }, { "defaultValue": "3", "description": "Specifies the maximum number of lines of text the textarea will expand to. @@ -20041,6 +20512,15 @@ 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\`. + +Requires React 18.", + "name": "menus", + "optional": true, + "type": "Array", + }, { "defaultValue": "1", "description": "Specifies the minimum number of lines of text to set the height to.", @@ -20049,7 +20529,7 @@ Defaults to 3. Use -1 for infinite rows.", "type": "number", }, { - "description": "Specifies the name of the control used in HTML forms.", + "description": "Specifies the name of the prompt input for form submissions.", "name": "name", "optional": true, "type": "string", @@ -20060,7 +20540,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", @@ -20106,8 +20587,6 @@ inadvertently sending data (such as user passwords) to third parties.", "type": "boolean", }, { - "description": "An object containing CSS properties to customize the prompt input's visual appearance. -Refer to the [style](/components/prompt-input/?tabId=style) tab for more details.", "inlineType": { "name": "PromptInputProps.Style", "properties": [ @@ -20338,9 +20817,62 @@ Refer to the [style](/components/prompt-input/?tabId=style) tab for more details "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') +- Trigger tokens: \`value\` contains the filter text, \`triggerChar\` for the trigger character + +When \`menus\` is defined, you should use \`tokens\` to control the content instead of \`value\`. + +Requires React 18.", + "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 + +Requires React 18.", + "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 \`menus\` is defined (token mode): +- This property is optional and defaults to empty string +- The actual content is managed via the \`tokens\` array +- \`onChange\` and \`onAction\` events will provide the serialized text value + +When \`menus\` is not defined (text mode): +- This property is required +- Represents the current text content of the textarea", "name": "value", - "optional": false, + "optional": true, "type": "string", }, { @@ -39367,6 +39899,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": [], @@ -39381,6 +39928,20 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "description": "Finds the menu dropdown (always in portal due to expandToViewport=true).", + "name": "findMenu", + "parameters": [], + "returnType": { + "isNullable": true, + "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": { @@ -39420,11 +39981,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, @@ -39432,7 +40001,51 @@ Returns the current value of the textarea.", }, }, { - "description": "Sets the value of the component and calls the onChange handler.", + "description": "Checks if the menu is currently open.", + "name": "isMenuOpen", + "parameters": [], + "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", + }, + ], + "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", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { "name": "setTextareaValue", "parameters": [ { @@ -39449,9 +40062,83 @@ 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": [ + { + "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": [ { @@ -48653,6 +49340,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": [], @@ -48662,6 +49359,20 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "description": "Finds the menu dropdown (always in portal due to expandToViewport=true).", + "name": "findMenu", + "parameters": [], + "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": { @@ -48689,6 +49400,60 @@ If not specified, the method returns the result text that is currently displayed ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "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 7b95f2f265..1d3ad1ef34 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -358,7 +358,7 @@ const getInteriorDropdownPosition = ( export const calculatePosition = ( dropdownElement: HTMLDivElement, - triggerElement: HTMLDivElement, + triggerElement: HTMLElement, verticalContainerElement: HTMLDivElement, interior: boolean, expandToViewport: boolean, diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index fa87bdf160..11f2fa13f2 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -170,6 +170,7 @@ const TransitionContent = ({ const Dropdown = ({ content, trigger, + triggerRef: externalTriggerRef, open, onOutsideClick, onMouseDown, @@ -180,6 +181,7 @@ const Dropdown = ({ stretchHeight = false, minWidth, maxWidth, + maxHeight, hideBlockBorder = true, expandToViewport = false, preferredAlignment = 'start', @@ -199,13 +201,16 @@ const Dropdown = ({ ariaDescribedby, }: DropdownProps) => { const wrapperRef = useRef(null); - const triggerRef = useRef(null); + const internalTriggerRef = useRef(null); const dropdownRef = useRef(null); const dropdownContainerRef = useRef(null); const verticalContainerRef = useRef(null); // To keep track of the initial position (drop up/down) which is kept the same during fixed repositioning const fixedPosition = useRef(null); + // Use external trigger ref if provided, otherwise use internal ref + const triggerRef = externalTriggerRef || internalTriggerRef; + const isRefresh = useVisualRefresh(); const dropdownClasses = usePortalModeClasses(triggerRef); @@ -226,7 +231,9 @@ const Dropdown = ({ target: HTMLDivElement, verticalContainer: HTMLDivElement ) => { - verticalContainer.style.maxBlockSize = position.blockSize; + // Apply maxBlockSize, constrained by maxHeight prop if provided + const constrainedBlockSize = maxHeight ? `min(${position.blockSize}, ${maxHeight}px)` : position.blockSize; + verticalContainer.style.maxBlockSize = constrainedBlockSize; // Only apply occupy-entire-width when matching trigger width exactly and not in portal mode if (!interior && matchTriggerWidth && !expandToViewport) { @@ -411,7 +418,7 @@ const Dropdown = ({ return () => { window.removeEventListener('click', clickListener, true); }; - }, [open, onOutsideClick]); + }, [open, onOutsideClick, triggerRef]); // subscribe to Escape key press useEffect(() => { @@ -457,7 +464,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport, isMobile]); + }, [open, expandToViewport, isMobile, triggerRef]); const referrerId = useUniqueId(); @@ -496,9 +503,15 @@ const Dropdown = ({ onFocus={focusHandler} onBlur={blurHandler} > -
- {trigger} -
+ {!externalTriggerRef && ( +
+ {trigger} +
+ )} dropdownRef.current && getFirstFocusable(dropdownRef.current)?.focus()} diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index f47757f6a9..b7146e8c72 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -63,6 +63,11 @@ export interface DropdownProps extends ExpandToViewport { */ trigger: React.ReactNode; + /** + * Optional ref to an element that should be used for positioning calculations. + */ + triggerRef?: React.RefObject; + /** * "Sticky" header of the dropdown content */ @@ -131,6 +136,12 @@ export interface DropdownProps extends ExpandToViewport { */ maxWidth?: DropdownWidthConstraint; + /** + * Maximum height constraint for the dropdown content in pixels. + * When set, constrains the calculated height to not exceed this value. + */ + maxHeight?: number; + /** * Preferred alignment of the dropdown relative to its trigger. * The dropdown will attempt this alignment first, but will automatically diff --git a/src/internal/vendor/react-dom-client-stub.ts b/src/internal/vendor/react-dom-client-stub.ts new file mode 100644 index 0000000000..0ef9c185dc --- /dev/null +++ b/src/internal/vendor/react-dom-client-stub.ts @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import ReactDOM from 'react-dom'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +// Stub for react-dom/client when React 18 is not available +// This provides React 16/17 compatibility using the legacy render API + +export interface Root { + render: (element: any) => void; + unmount: () => void; +} + +// Map to track which containers have been rendered to +const containerMap = new Map(); + +let hasWarned = false; + +// Stub createRoot that uses legacy ReactDOM.render for React 16/17 +export function createRoot(container: HTMLElement): Root { + if (!hasWarned) { + warnOnce( + 'PromptInput', + 'Token mode features (menus, tokens) are using React 16/17 compatibility mode. For optimal performance and features, upgrade to React 18+.' + ); + hasWarned = true; + } + + containerMap.set(container, true); + + return { + render: (element: any) => { + ReactDOM.render(element, container); + }, + unmount: () => { + ReactDOM.unmountComponentAtNode(container); + containerMap.delete(container); + }, + }; +} diff --git a/src/prompt-input/components/menu-dropdown.tsx b/src/prompt-input/components/menu-dropdown.tsx new file mode 100644 index 0000000000..c0d794b3c9 --- /dev/null +++ b/src/prompt-input/components/menu-dropdown.tsx @@ -0,0 +1,69 @@ +// 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 { MenuItemsHandlers, MenuItemsState } from '../core/menu-state'; +import { PromptInputProps } from '../interfaces'; + +interface MenuDropdownProps { + 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; +} + +const createMouseEventHandler = (handler: (index: number) => void) => (itemIndex: number) => { + if (itemIndex > -1) { + handler(itemIndex); + } +}; + +export default function MenuDropdown({ + menu, + statusType, + menuItemsState, + menuItemsHandlers, + highlightedOptionId, + highlightText, + listId, + controlId, + handleLoadMore, + hasDropdownStatus, + listBottom, + ariaDescribedby, +}: MenuDropdownProps) { + const handleMouseUp = createMouseEventHandler(menuItemsHandlers.selectVisibleOptionWithMouse); + const handleMouseMove = createMouseEventHandler(menuItemsHandlers.highlightVisibleOptionWithMouse); + + const ListComponent = menu.virtualScroll ? VirtualList : PlainList; + + return ( + + ); +} diff --git a/src/prompt-input/components/textarea-mode.tsx b/src/prompt-input/components/textarea-mode.tsx new file mode 100644 index 0000000000..9835afdd6d --- /dev/null +++ b/src/prompt-input/components/textarea-mode.tsx @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import WithNativeAttributes from '../../internal/utils/with-native-attributes'; + +interface TextareaModeProps { + textareaRef: React.RefObject; + controlId?: string; + textareaAttributes: React.TextareaHTMLAttributes; + nativeTextareaAttributes?: Record; +} + +export default function TextareaMode({ + textareaRef, + controlId, + textareaAttributes, + nativeTextareaAttributes, +}: TextareaModeProps) { + return ( + + ); +} diff --git a/src/prompt-input/components/token-mode.tsx b/src/prompt-input/components/token-mode.tsx new file mode 100644 index 0000000000..ff51100194 --- /dev/null +++ b/src/prompt-input/components/token-mode.tsx @@ -0,0 +1,164 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import Dropdown from '../../internal/components/dropdown'; +import DropdownFooter from '../../internal/components/dropdown-footer'; +import { MenuItemsHandlers, MenuItemsState } from '../core/menu-state'; +import { PromptInputProps } from '../interfaces'; +import MenuDropdown from './menu-dropdown'; + +import styles from '../styles.css.js'; +import testutilStyles from '../test-classes/styles.css.js'; + +interface TokenModeProps { + // Refs + editableElementRef: React.RefObject; + triggerWrapperRef: React.MutableRefObject; + + // IDs + controlId?: string; + menuListId: string; + menuFooterControlId: string; + highlightedMenuOptionId?: string; + + // State + name?: string; + getPlainTextValue: () => string; + menuIsOpen: boolean; + triggerWrapperReady: boolean; + shouldRenderMenuDropdown: boolean; + + // Menu data + activeMenu: PromptInputProps.MenuDefinition | null; + activeTriggerToken: PromptInputProps.TriggerToken | null; + menuFilterText: string; + menuItemsState: MenuItemsState | null; + menuItemsHandlers: MenuItemsHandlers | null; + menuDropdownStatus: any; + + // Handlers + handleInput: () => void; + handleLoadMore: () => void; + + // Attributes + editableElementAttributes: React.HTMLAttributes & { + 'data-placeholder'?: string; + }; + + // i18n + i18nStrings?: PromptInputProps['i18nStrings']; + + maxMenuHeight?: number; +} + +const MENU_MIN_WIDTH = 300; + +export default function TokenMode({ + editableElementRef, + triggerWrapperRef, + controlId, + menuListId, + menuFooterControlId, + highlightedMenuOptionId, + name, + getPlainTextValue, + menuIsOpen, + triggerWrapperReady, + shouldRenderMenuDropdown, + activeMenu, + activeTriggerToken, + menuFilterText, + menuItemsState, + menuItemsHandlers, + menuDropdownStatus, + maxMenuHeight, + handleInput, + handleLoadMore, + editableElementAttributes, +}: TokenModeProps) { + return ( + <> + {name && } +
+
+ 0 || menuDropdownStatus?.content) + ) + } + trigger={null} + triggerRef={triggerWrapperRef} + contentKey={ + triggerWrapperReady + ? `trigger-${activeTriggerToken?.id}-${activeTriggerToken?.triggerChar}-${menuFilterText}` + : undefined + } + onMouseDown={event => { + event.preventDefault(); + }} + footer={ + menuDropdownStatus?.isSticky && menuDropdownStatus.content ? ( + = 1 : false} + /> + ) : null + } + content={ + <> + {shouldRenderMenuDropdown && menuItemsState && menuItemsHandlers && activeMenu && ( + + ) : null + } + ariaDescribedby={menuDropdownStatus?.content ? menuFooterControlId : undefined} + /> + )} + + } + /> +
+ + ); +} diff --git a/src/prompt-input/core/constants.ts b/src/prompt-input/core/constants.ts new file mode 100644 index 0000000000..0810157769 --- /dev/null +++ b/src/prompt-input/core/constants.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const ELEMENT_TYPES = { + REFERENCE: 'reference', + PINNED: 'pinned', + CURSOR_SPOT_BEFORE: 'cursor-spot-before', + CURSOR_SPOT_AFTER: 'cursor-spot-after', + TRIGGER: 'trigger', + TRAILING_BREAK: 'trailing-break', +}; + +export const SPECIAL_CHARS = { + ZWNJ: '\u200B', + NEWLINE: '\n', +}; + +export const DEFAULT_MAX_ROWS = 3; +export const NEXT_TICK_TIMEOUT = 0; +export const CURSOR_DETECTION_DELAY = 100; diff --git a/src/prompt-input/core/cursor-manager.ts b/src/prompt-input/core/cursor-manager.ts new file mode 100644 index 0000000000..74def54568 --- /dev/null +++ b/src/prompt-input/core/cursor-manager.ts @@ -0,0 +1,353 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { findAllParagraphs, findElement, getTokenType } from './dom-utils'; +import { isBreakToken, isHTMLElement, isTextNode, isTextToken, isTriggerToken } from './type-guards'; + +// HELPER FUNCTIONS + +function isReferenceTokenType(tokenType: string | null): boolean { + return tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED; +} + +/** + * Gets the length of a token element in the DOM. + * - Text nodes: their text length + * - Trigger tokens: full text length (including trigger char, e.g., "@bob" = 4) + * - Reference/pinned tokens: 1 (atomic) + */ +function getTokenElementLength(child: Node): number { + if (isTextNode(child)) { + return child.textContent?.length || 0; + } + + if (isHTMLElement(child)) { + const tokenType = getTokenType(child); + if (tokenType === ELEMENT_TYPES.TRIGGER) { + return child.textContent?.length || 0; + } + if (isReferenceTokenType(tokenType)) { + return 1; + } + } + + return 0; +} + +// BASIC CURSOR POSITIONING + +/** + * Generic function to position cursor using a range configuration callback. + */ +function positionCursor(configureRange: (range: Range) => void): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + configureRange(range); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + +export function positionBefore(node: Node): void { + positionCursor(range => range.setStartBefore(node)); +} + +export function positionAfter(node: Node): void { + positionCursor(range => range.setStartAfter(node)); +} + +export function positionAtStartOfParagraph(paragraph: HTMLElement): void { + positionCursor(range => range.setStart(paragraph, 0)); +} + +function positionCursorAtOffset(node: Node, offset: number): void { + positionCursor(range => range.setStart(node, offset)); +} + +// POSITION CALCULATION + +function countParagraphContent(p: Element): number { + let count = 0; + for (const child of Array.from(p.childNodes)) { + count += getTokenElementLength(child); + } + return count; +} + +function countUpToCursor(p: Element, range: Range): number { + let count = 0; + + // Special case: cursor is at paragraph level (between child nodes) + if (range.startContainer === p) { + for (let i = 0; i < range.startOffset && i < p.childNodes.length; i++) { + count += getTokenElementLength(p.childNodes[i]); + } + return count; + } + + for (const child of Array.from(p.childNodes)) { + const childContainsCursor = child === range.startContainer || child.contains(range.startContainer); + + if (childContainsCursor) { + if (isTextNode(child)) { + count += range.startOffset; + } else if (isHTMLElement(child)) { + const tokenType = getTokenType(child); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + const triggerTextNode = child.childNodes[0]; + if (triggerTextNode && isTextNode(triggerTextNode) && triggerTextNode === range.startContainer) { + count += range.startOffset; + } + } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + const cursorSpotBefore = findElement(child, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + const cursorSpotAfter = findElement(child, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); + + const cursorInBefore = + cursorSpotBefore && + (cursorSpotBefore === range.startContainer || cursorSpotBefore.contains(range.startContainer)); + const cursorInAfter = + cursorSpotAfter && + (cursorSpotAfter === range.startContainer || cursorSpotAfter.contains(range.startContainer)); + + if (cursorInBefore) { + const beforeContent = (cursorSpotBefore!.textContent || '').replace( + new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), + '' + ); + if (beforeContent && isTextNode(range.startContainer)) { + count += range.startOffset; + } + } else if (cursorInAfter) { + count += 1; + + const afterContent = (cursorSpotAfter!.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (afterContent && isTextNode(range.startContainer)) { + const contentOffset = Math.max(0, range.startOffset - 1); + count += contentOffset; + } + } else { + count += 1; + } + } + } + break; + } + + count += getTokenElementLength(child); + } + + return count; +} + +export function getCursorPosition(element: HTMLElement): number { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return 0; + } + + const range = selection.getRangeAt(0); + + if (!element.contains(range.startContainer)) { + return 0; + } + + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + return 0; + } + + let position = 0; + + for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) { + const p = paragraphs[pIndex]; + + if (pIndex > 0) { + position += 1; // Line break + } + + if (!p.contains(range.startContainer)) { + position += countParagraphContent(p); + } else { + position += countUpToCursor(p, range); + break; + } + } + + return position; +} + +// NUMERIC POSITION TO DOM LOCATION + +interface DOMLocation { + node: Node; + offset: number; +} + +function findPositionInDOM(element: HTMLElement, position: number): DOMLocation | null { + const paragraphs = findAllParagraphs(element); + let cursorPos = 0; + + for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) { + const p = paragraphs[pIndex]; + + if (pIndex > 0) { + cursorPos += 1; + if (cursorPos >= position) { + return { node: p, offset: 0 }; + } + } + + const paragraphLength = countParagraphContent(p); + + if (cursorPos + paragraphLength >= position) { + const targetOffset = position - cursorPos; + let offsetInParagraph = 0; + + for (const child of Array.from(p.childNodes)) { + if (isTextNode(child)) { + const textLength = child.textContent?.length || 0; + + if (offsetInParagraph + textLength >= targetOffset) { + return { node: child, offset: targetOffset - offsetInParagraph }; + } + + offsetInParagraph += textLength; + } else if (isHTMLElement(child)) { + const tokenType = getTokenType(child); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + const triggerLength = child.textContent?.length || 0; + + if (offsetInParagraph + triggerLength >= targetOffset) { + const offsetInTrigger = targetOffset - offsetInParagraph; + const triggerTextNode = child.childNodes[0]; + if (triggerTextNode && isTextNode(triggerTextNode)) { + return { node: triggerTextNode, offset: offsetInTrigger }; + } + } + + offsetInParagraph += triggerLength; + } else if (isReferenceTokenType(tokenType)) { + if (offsetInParagraph === targetOffset) { + return { node: p, offset: Array.from(p.childNodes).indexOf(child) }; + } + + offsetInParagraph += 1; + + if (offsetInParagraph === targetOffset) { + const nextSibling = child.nextSibling; + if (nextSibling) { + return isTextNode(nextSibling) + ? { node: nextSibling, offset: 0 } + : { node: p, offset: Array.from(p.childNodes).indexOf(nextSibling) }; + } + return { node: p, offset: p.childNodes.length }; + } + } + } + } + + return p.lastChild && isTextNode(p.lastChild) + ? { node: p.lastChild, offset: p.lastChild.textContent?.length || 0 } + : { node: p, offset: p.childNodes.length }; + } + + cursorPos += paragraphLength; + } + + const lastP = paragraphs[paragraphs.length - 1]; + if (lastP) { + return lastP.lastChild && isTextNode(lastP.lastChild) + ? { node: lastP.lastChild, offset: lastP.lastChild.textContent?.length || 0 } + : { node: lastP, offset: lastP.childNodes.length }; + } + + return null; +} + +export function setCursorPosition(element: HTMLElement, position: number): void { + const location = findPositionInDOM(element, position); + if (location) { + positionCursorAtOffset(location.node, location.offset); + } +} + +export function setCursorRange(element: HTMLElement, start: number, end: number): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const startLocation = findPositionInDOM(element, start); + const endLocation = findPositionInDOM(element, 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); +} + +// TOKEN CURSOR CALCULATIONS + +export function getTokenCursorLength(token: PromptInputProps.InputToken): number { + if (isTextToken(token)) { + return token.value.length; + } + if (isBreakToken(token)) { + return 1; // Line break counts as 1 position + } + if (isTriggerToken(token)) { + return 1 + token.value.length; // trigger char + value + } + return 1; // references +} + +export function getCursorPositionAtIndex(tokens: readonly PromptInputProps.InputToken[], index: number): number { + let position = 0; + + for (let i = 0; i <= index && i < tokens.length; i++) { + position += getTokenCursorLength(tokens[i]); + } + + return position; +} + +// TRIGGER TOKEN UTILITIES + +/** + * Checks if the current cursor position is inside a trigger token element. + * @param element The contentEditable element + * @returns true if cursor is inside a trigger token, false otherwise + */ +export function isCursorInTriggerToken(element: HTMLElement): boolean { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + let node: Node | null = range.startContainer; + + // Walk up the DOM tree to check if we're inside a trigger token + while (node && node !== element) { + if (isHTMLElement(node) && getTokenType(node) === ELEMENT_TYPES.TRIGGER) { + return true; + } + node = node.parentNode; + } + + return false; +} diff --git a/src/prompt-input/core/cursor-utils.ts b/src/prompt-input/core/cursor-utils.ts new file mode 100644 index 0000000000..b9eb0afe55 --- /dev/null +++ b/src/prompt-input/core/cursor-utils.ts @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { getCursorPosition, getTokenCursorLength, setCursorPosition } from './cursor-manager'; +import { findElements, getTokenType, insertAfter } from './dom-utils'; +import { isTextNode } from './type-guards'; + +declare global { + interface Window { + isMouseDown?: boolean; + isMouseDownForCursor?: boolean; + } +} + +export interface CursorSpotExtractionResult { + movedTextNode: Text | null; +} + +export function extractTextFromCursorSpots( + paragraphs: HTMLElement[], + trackCursor: boolean = true +): CursorSpotExtractionResult { + let movedTextNode: Text | null = null; + + paragraphs.forEach(p => { + const cursorSpots = findElements(p, { + tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER], + }); + + cursorSpots.forEach(spot => { + const content = spot.textContent || ''; + const cleanContent = content.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + + if (cleanContent) { + let cursorWasHere = false; + if (trackCursor) { + const selection = window.getSelection(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + if (spot.contains(range.startContainer)) { + cursorWasHere = true; + } + } + } + + const textNode = document.createTextNode(cleanContent); + const wrapper = spot.parentElement; + + if (wrapper) { + if (spot.getAttribute('data-type') === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { + wrapper.parentNode?.insertBefore(textNode, wrapper); + } else { + insertAfter(textNode, wrapper); + } + } + + if (cursorWasHere) { + movedTextNode = textNode; + } + } + + spot.textContent = SPECIAL_CHARS.ZWNJ; + }); + }); + + return { movedTextNode }; +} + +export function positionCursorAfterMovedText( + movedTextNode: Text, + element: HTMLElement, + lastKnownCursorPositionRef: React.MutableRefObject +): void { + const range = document.createRange(); + range.setStart(movedTextNode, movedTextNode.textContent?.length || 0); + range.collapse(true); + + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + + const newPos = getCursorPosition(element); + lastKnownCursorPositionRef.current = newPos; +} + +export function setCursorOverride(state: EditableState, position: number, paragraphId: string | null = null): void { + state.cursorPositionOverride = { cursorPosition: position, paragraphId }; + state.skipCursorRestore = false; +} + +export function applySafariCursorFix(element: HTMLDivElement, state: EditableState, position: number): void { + if (state.isDeleteOperation) { + state.isDeleteOperation = false; + setCursorPosition(element, position); + + // Collapse selection to force Safari to update cursor rendering + // This avoids screenreader disruption from blur/focus + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + selection.collapse(range.startContainer, range.startOffset); + } + } +} + +export function calculateTokenPosition( + tokens: readonly PromptInputProps.InputToken[], + targetIndex: number, + includeTarget: boolean = false +): number { + let position = 0; + const endIndex = includeTarget ? targetIndex : targetIndex - 1; + + for (let i = 0; i <= endIndex && i < tokens.length; i++) { + position += getTokenCursorLength(tokens[i]); + } + + return position; +} + +export function calculateEndPosition(tokens: readonly PromptInputProps.InputToken[]): number { + return tokens.reduce((sum, token) => sum + getTokenCursorLength(token), 0); +} + +export function getCurrentSelection(): Selection | null { + return window.getSelection(); +} + +export function getFirstRange(): Range | null { + const selection = getCurrentSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + return selection.getRangeAt(0); +} + +export function selectAllContent(element: HTMLElement): void { + const selection = getCurrentSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + + selection.removeAllRanges(); + selection.addRange(range); +} + +export function normalizeSelection(selection: Selection | null, skipCursorSpots: boolean = false): void { + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + if (range.collapsed || window.isMouseDown || skipCursorSpots) { + return; + } + + const normalizeBoundary = (container: Node) => { + if (!isTextNode(container)) { + return null; + } + + const parent = container.parentElement; + if (!parent) { + return null; + } + + const parentType = getTokenType(parent); + if (parentType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && parentType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + return null; + } + + const wrapper = parent.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (!wrapper || (wrapperType !== ELEMENT_TYPES.REFERENCE && wrapperType !== ELEMENT_TYPES.PINNED)) { + return null; + } + + const paragraph = wrapper.parentElement; + if (!paragraph) { + return null; + } + + const wrapperIndex = Array.from(paragraph.childNodes).indexOf(wrapper); + const newOffset = parentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE ? wrapperIndex : wrapperIndex + 1; + + return { container: paragraph, offset: newOffset }; + }; + + const normalizedStart = normalizeBoundary(range.startContainer); + const normalizedEnd = normalizeBoundary(range.endContainer); + + if (normalizedStart || normalizedEnd) { + const updatedRange = document.createRange(); + updatedRange.setStart( + normalizedStart?.container ?? range.startContainer, + normalizedStart?.offset ?? range.startOffset + ); + updatedRange.setEnd(normalizedEnd?.container ?? range.endContainer, normalizedEnd?.offset ?? range.endOffset); + selection.removeAllRanges(); + selection.addRange(updatedRange); + } +} diff --git a/src/prompt-input/core/dom-utils.ts b/src/prompt-input/core/dom-utils.ts new file mode 100644 index 0000000000..85a5a4d53f --- /dev/null +++ b/src/prompt-input/core/dom-utils.ts @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ELEMENT_TYPES } from './constants'; + +import styles from '../styles.css.js'; + +export function getTokenType(element: HTMLElement): string | null { + return element.getAttribute('data-type'); +} + +export function insertAfter(newNode: Node, referenceNode: Node): void { + const parent = referenceNode.parentNode; + if (!parent) { + return; + } + + if (referenceNode.nextSibling) { + parent.insertBefore(newNode, referenceNode.nextSibling); + } else { + parent.appendChild(newNode); + } +} + +export function createParagraph(): HTMLParagraphElement { + const p = document.createElement('p'); + p.className = styles.paragraph || 'paragraph'; + p.setAttribute('data-paragraph-id', generateTokenId('p')); + return p; +} + +export function createTrailingBreak(): HTMLBRElement { + const br = document.createElement('br'); + br.setAttribute('data-id', ELEMENT_TYPES.TRAILING_BREAK); + return br; +} + +export function generateTokenId(prefix: string): string { + return `${prefix}-${Date.now()}`; +} + +interface TokenQueryOptions { + tokenType?: string | string[]; + tokenId?: string; +} + +function buildTokenSelector(options: TokenQueryOptions): string { + const { tokenType, tokenId } = options; + + let selector = ''; + + if (tokenType) { + const types = Array.isArray(tokenType) ? tokenType : [tokenType]; + selector = types.map(type => `[data-type="${type}"]`).join(', '); + } + + if (tokenId) { + selector += `[data-id="${tokenId}"]`; + } + + return selector; +} + +export function findElements(container: HTMLElement, options: TokenQueryOptions): HTMLElement[] { + const selector = buildTokenSelector(options); + return selector ? Array.from(container.querySelectorAll(selector)) : []; +} + +export function findElement(container: HTMLElement, options: TokenQueryOptions): HTMLElement | null { + const selector = buildTokenSelector(options); + return selector ? container.querySelector(selector) : null; +} + +export function findAllParagraphs(container: HTMLElement): HTMLParagraphElement[] { + return Array.from(container.querySelectorAll('p')); +} + +export function isElementEffectivelyEmpty(element: HTMLElement): boolean { + if (element.childNodes.length === 0) { + return true; + } + + for (const child of Array.from(element.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + if (child.textContent && child.textContent.trim() !== '') { + return false; + } + } else { + return false; + } + } + return true; +} + +export function hasOnlyTrailingBR(paragraph: HTMLElement): boolean { + return paragraph.childNodes.length === 1 && paragraph.firstChild?.nodeName === 'BR'; +} + +export function isEmptyState(element: HTMLElement): boolean { + const paragraphs = findAllParagraphs(element); + return paragraphs.length === 0 || (paragraphs.length === 1 && hasOnlyTrailingBR(paragraphs[0])); +} + +export function ensureValidEmptyState(element: HTMLElement): void { + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + const p = createParagraph(); + p.appendChild(createTrailingBreak()); + element.appendChild(p); + } else if (paragraphs.length === 1) { + const p = paragraphs[0]; + + if (hasOnlyTrailingBR(p)) { + return; + } + + while (p.firstChild) { + p.removeChild(p.firstChild); + } + p.appendChild(createTrailingBreak()); + } else { + while (paragraphs.length > 1) { + paragraphs[paragraphs.length - 1].remove(); + paragraphs.pop(); + } + const p = paragraphs[0]; + while (p.firstChild) { + p.removeChild(p.firstChild); + } + p.appendChild(createTrailingBreak()); + } +} diff --git a/src/prompt-input/core/event-handlers.ts b/src/prompt-input/core/event-handlers.ts new file mode 100644 index 0000000000..25b55c4ccf --- /dev/null +++ b/src/prompt-input/core/event-handlers.ts @@ -0,0 +1,631 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES } from './constants'; +import { getTokenCursorLength, positionAfter, positionBefore } from './cursor-manager'; +import { calculateTokenPosition, setCursorOverride } from './cursor-utils'; +import { + createParagraph, + createTrailingBreak, + findAllParagraphs, + getTokenType, + insertAfter, + isElementEffectivelyEmpty, +} from './dom-utils'; +import { MenuItemsHandlers, MenuItemsState } from './menu-state'; +import { extractTokensFromDOM, getPromptText } from './token-operations'; +import { findAdjacentToken } from './token-utils'; +import { handleSpaceInOpenMenu } from './trigger-utils'; +import { isBreakToken, isHTMLElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; + +// TYPES + +export type { EditableState }; + +export interface KeyboardHandlerProps { + getMenuOpen: () => boolean; + getMenuItemsState: () => MenuItemsState | null; + getMenuItemsHandlers: () => MenuItemsHandlers | null; + getMenuStatusType?: () => PromptInputProps.MenuDefinition['statusType']; + onAction?: (detail: PromptInputProps.ActionDetail) => void; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + tokens?: readonly PromptInputProps.InputToken[]; + closeMenu: () => void; + announceTokenOperation?: (message: string) => void; + i18nStrings?: PromptInputProps.I18nStrings; + disabled?: boolean; + readOnly?: boolean; + editableState?: EditableState; + editableElementRef?: React.RefObject; + lastKnownCursorPositionRef?: React.MutableRefObject; +} + +// KEYBOARD HANDLERS + +export function createKeyboardHandlers(props: KeyboardHandlerProps) { + function handleMenuNavigation(event: React.KeyboardEvent): boolean { + const menuItemsState = props.getMenuItemsState(); + const menuItemsHandlers = props.getMenuItemsHandlers(); + const menuOpen = props.getMenuOpen(); + + if (!menuOpen || !menuItemsHandlers || !menuItemsState) { + return false; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + + const delta = event.key === 'ArrowDown' ? 1 : -1; + menuItemsHandlers.moveHighlightWithKeyboard(delta); + return true; + } + + if ((event.key === 'Enter' || event.key === 'Tab') && !event.shiftKey) { + event.preventDefault(); + return menuItemsHandlers.selectHighlightedOptionWithKeyboard(); + } + + if (event.key === ' ') { + return handleSpaceInOpenMenu(event, { + menuItemsState, + menuItemsHandlers, + getMenuStatusType: props.getMenuStatusType, + closeMenu: props.closeMenu, + editableElementRef: props.editableElementRef, + lastKnownCursorPositionRef: props.lastKnownCursorPositionRef, + editableState: props.editableState, + }); + } + + if (event.key === 'Escape') { + event.preventDefault(); + props.closeMenu(); + return true; + } + + return false; + } + + function handleEnterKey(event: React.KeyboardEvent): void { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) { + return; + } + + // Don't submit if disabled or readonly (match textarea behavior) + if (props.disabled || props.readOnly) { + event.preventDefault(); + return; + } + + const currentTarget = event.currentTarget; + if (!isHTMLElement(currentTarget)) { + return; + } + + const form = currentTarget.closest('form'); + if (form && !event.isDefaultPrevented()) { + form.requestSubmit(); + } + event.preventDefault(); + + const plainText = props.tokensToText ? props.tokensToText(props.tokens ?? []) : getPromptText(props.tokens ?? []); + + if (props.onAction) { + props.onAction({ value: plainText, tokens: [...(props.tokens ?? [])] }); + } + } + + return { + handleMenuNavigation, + handleEnterKey, + }; +} + +// PARAGRAPH OPERATIONS + +function findParagraphAncestor(node: Node): HTMLElement | null { + let current: Node | null = node; + while (current && current.nodeName !== 'P') { + current = current.parentNode; + } + return isHTMLElement(current) ? current : null; +} + +export function splitParagraphAtCursor( + editableElement: HTMLDivElement, + state: EditableState, + suppressInputEvent = false +): void { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + const currentP = findParagraphAncestor(range.startContainer); + + if (!currentP?.parentNode) { + return; + } + + // Extract content after cursor + const afterRange = document.createRange(); + afterRange.setStart(range.startContainer, range.startOffset); + afterRange.setEndAfter(currentP.lastChild || currentP); + const afterContent = afterRange.extractContents(); + + // Create new paragraph with the extracted content + const newP = createParagraph(); + newP.appendChild(afterContent); + + // Ensure both paragraphs have proper structure + if (isElementEffectivelyEmpty(newP)) { + newP.appendChild(createTrailingBreak()); + } + + if (isElementEffectivelyEmpty(currentP)) { + currentP.appendChild(createTrailingBreak()); + } + + currentP.parentNode.insertBefore(newP, currentP.nextSibling); + + // Calculate cursor position for the new paragraph (at its start) + const paragraphs = findAllParagraphs(editableElement); + const currentPIndex = paragraphs.findIndex(p => p === currentP); + + let cursorPosition = 0; + const tokens = extractTokensFromDOM(editableElement); + let breakCount = 0; + + for (const token of tokens) { + if (isBreakToken(token)) { + breakCount++; + cursorPosition += 1; + if (breakCount > currentPIndex) { + break; + } + } else { + cursorPosition += getTokenCursorLength(token); + } + } + + state.skipCursorRestore = false; + state.targetParagraphId = newP.getAttribute('data-paragraph-id'); + state.cursorPositionOverride = { + cursorPosition, + paragraphId: newP.getAttribute('data-paragraph-id'), + }; + + if (!suppressInputEvent) { + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + } +} + +// TOKEN DELETION HELPERS + +interface TokenElementResult { + targetElement: HTMLElement | null; + wrapperElement: HTMLElement | null; +} + +function findTokenElementForDeletion(container: Node, offset: number, isBackspace: boolean): TokenElementResult { + let adjacent: Node | null = null; + + if (isTextNode(container)) { + const isAtEdge = isBackspace ? offset === 0 : offset === (container.textContent?.length || 0); + if (isAtEdge) { + adjacent = isBackspace ? container.previousSibling : container.nextSibling; + } + } else if (isHTMLElement(container)) { + const childIndex = isBackspace ? offset - 1 : offset; + adjacent = container.childNodes[childIndex]; + } + + if (isHTMLElement(adjacent)) { + const adjacentType = getTokenType(adjacent); + if (adjacentType === ELEMENT_TYPES.REFERENCE || adjacentType === ELEMENT_TYPES.PINNED) { + return { + wrapperElement: adjacent, + targetElement: adjacent, + }; + } + } + + return { targetElement: null, wrapperElement: null }; +} + +function isValidTokenForDeletion(element: HTMLElement | null): boolean { + if (!element) { + return false; + } + const tokenType = getTokenType(element); + return tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED; +} + +export function handleReferenceTokenDeletion( + event: React.KeyboardEvent, + isBackspace: boolean, + editableElement: HTMLDivElement, + state: EditableState, + announceTokenOperation?: (message: string) => void, + i18nStrings?: PromptInputProps.I18nStrings +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + // If there's a selection range (not just a cursor), let the browser handle it + // The input event will trigger token extraction which will properly handle reference removal + if (!range.collapsed) { + return false; + } + + const { targetElement, wrapperElement } = findTokenElementForDeletion( + range.startContainer, + range.startOffset, + isBackspace + ); + + const tokenElement = targetElement || wrapperElement || null; + + if (!isValidTokenForDeletion(tokenElement)) { + return false; + } + + event.preventDefault(); + + // Announce token removal + const tokenLabel = tokenElement!.textContent?.trim() || ''; + if (announceTokenOperation && tokenLabel) { + const announcement = + i18nStrings?.tokenRemovedAriaLabel?.({ label: tokenLabel, value: tokenLabel }) ?? `${tokenLabel} removed`; + announceTokenOperation(announcement); + } + + const elementToRemove = (wrapperElement || tokenElement)!; + const paragraph = elementToRemove.parentNode; + if (!isHTMLElement(paragraph)) { + return true; + } + + state.skipNextZwnjUpdate = true; + state.skipNormalization = true; + + // Find the reference token's position in the token array + // This gives us the correct position independent of DOM structure + const instanceId = tokenElement!.getAttribute('data-id'); + const tokens = extractTokensFromDOM(editableElement); + const referenceIndex = tokens.findIndex(t => isReferenceToken(t) && t.id === instanceId); + + let cursorPosition = 0; + if (referenceIndex >= 0) { + // Calculate position up to (but not including) the reference + cursorPosition = calculateTokenPosition(tokens, referenceIndex, false); + } + + // Store the position for restoration after re-render + setCursorOverride(state, cursorPosition); + state.isDeleteOperation = true; // Mark as deletion for Safari ghost cursor fix + + elementToRemove.remove(); + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + + return true; +} + +// ARROW KEY NAVIGATION + +function handleArrowNavigation( + event: React.KeyboardEvent, + container: Node, + offset: number, + skipNormalizationRef: React.MutableRefObject +): boolean { + const direction = event.key === 'ArrowLeft' ? 'left' : 'right'; + const { sibling, isReferenceToken } = findAdjacentToken(container, offset, direction); + + if (isReferenceToken && sibling) { + event.preventDefault(); + skipNormalizationRef.current = true; + direction === 'left' ? positionBefore(sibling) : positionAfter(sibling); + return true; + } + + return false; +} + +export function handleArrowKeyNavigation( + event: React.KeyboardEvent, + skipNormalizationRef: React.MutableRefObject +): boolean { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return false; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + // Handle Shift+Arrow for selection across reference tokens + if (event.shiftKey) { + return handleShiftArrowAcrossTokens(event, selection, range); + } + + return handleArrowNavigation(event, range.startContainer, range.startOffset, skipNormalizationRef); +} + +function handleShiftArrowAcrossTokens( + event: React.KeyboardEvent, + selection: Selection, + range: Range +): boolean { + const isLeftArrow = event.key === 'ArrowLeft'; + + // For Shift+Arrow, we need to check the moving end of the selection + // Left arrow moves the start, right arrow moves the end + const relevantContainer = isLeftArrow ? range.startContainer : range.endContainer; + const relevantOffset = isLeftArrow ? range.startOffset : range.endOffset; + + // Check if we're immediately adjacent to a reference token (treating it as atomic) + let sibling: Node | null = null; + + if (isTextNode(relevantContainer)) { + // In text node - check if at start/end boundary + if (isLeftArrow && relevantOffset === 0) { + sibling = relevantContainer.previousSibling; + } else if (!isLeftArrow && relevantOffset === (relevantContainer.textContent?.length || 0)) { + sibling = relevantContainer.nextSibling; + } + } else if (isHTMLElement(relevantContainer)) { + // In element node (paragraph) - check adjacent child + if (isLeftArrow && relevantOffset > 0) { + sibling = relevantContainer.childNodes[relevantOffset - 1]; + } else if (!isLeftArrow && relevantOffset < relevantContainer.childNodes.length) { + sibling = relevantContainer.childNodes[relevantOffset]; + } + } + + if (!sibling) { + return false; + } + + const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; + if (siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED) { + event.preventDefault(); + + // Extend selection to include the entire reference token (atomic) + const newRange = range.cloneRange(); + if (isLeftArrow) { + newRange.setStartBefore(sibling); + } else { + newRange.setEndAfter(sibling); + } + + selection.removeAllRanges(); + selection.addRange(newRange); + return true; + } + + return false; +} + +// CURSOR NORMALIZATION + +function normalizeCursorInCursorSpot(container: Node): void { + if (!isTextNode(container)) { + return; + } + + const parent = container.parentElement; + if (!parent) { + return; + } + + const parentType = getTokenType(parent); + if (parentType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && parentType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + return; + } + + const wrapper = parent.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (!wrapper || (wrapperType !== ELEMENT_TYPES.REFERENCE && wrapperType !== ELEMENT_TYPES.PINNED)) { + return; + } + + const paragraph = wrapper.parentElement; + if (paragraph?.nodeName !== 'P') { + return; + } + + parentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE ? positionBefore(wrapper) : positionAfter(wrapper); +} + +export function createCursorNormalizationHandler( + editableElementRef: React.RefObject, + skipNormalizationRef: React.MutableRefObject, + state: EditableState +): () => void { + return () => { + if (skipNormalizationRef.current) { + skipNormalizationRef.current = false; + return; + } + + if (state.skipNormalization) { + state.skipNormalization = false; + return; + } + + const editableElement = editableElementRef.current; + if (!editableElement) { + return; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + // Skip normalization if there's an active selection (not just a collapsed cursor) + // This allows text selection including reference tokens to work correctly + if (!range.collapsed) { + return; + } + + normalizeCursorInCursorSpot(range.startContainer); + }; +} + +// SPACE AFTER CLOSED TRIGGER + +export function handleSpaceAfterClosedTrigger( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + menuOpen: boolean, + triggerValueWhenClosed: string, + editableState: EditableState, + ignoreCursorDetection: React.MutableRefObject, + menus?: readonly PromptInputProps.MenuDefinition[] +): boolean { + // Only handle space key when menu is closed + // triggerValueWhenClosed can be empty string (trigger with no filter) or non-empty (trigger with filter) + if (event.key !== ' ' || menuOpen) { + return false; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + if (!range.collapsed) { + return false; + } + + // Check if cursor is at the end of a trigger element + let triggerElement: HTMLElement | null = null; + let cursorAtEnd = false; + + if (isTextNode(range.startContainer)) { + const parent = range.startContainer.parentElement; + const parentType = parent ? getTokenType(parent) : null; + + if (parentType === ELEMENT_TYPES.TRIGGER && parent) { + triggerElement = parent; + const textLength = range.startContainer.textContent?.length || 0; + cursorAtEnd = range.startOffset === textLength; + } + } else if (isHTMLElement(range.startContainer)) { + // Cursor might be positioned in the paragraph after the trigger + const container = range.startContainer; + if (range.startOffset > 0) { + const prevNode = container.childNodes[range.startOffset - 1]; + if (isHTMLElement(prevNode) && getTokenType(prevNode) === ELEMENT_TYPES.TRIGGER) { + triggerElement = prevNode; + cursorAtEnd = true; + } + } + } + + if (!triggerElement || !cursorAtEnd) { + return false; + } + + // Prevent default space insertion + event.preventDefault(); + + // Get the paragraph containing the trigger + const paragraph = triggerElement.parentElement; + if (!paragraph || paragraph.nodeName !== 'P') { + return false; + } + + // Insert space after trigger + const spaceNode = document.createTextNode(' '); + insertAfter(spaceNode, triggerElement); + + // Calculate cursor position: after trigger + after space + const tokens = extractTokensFromDOM(editableElement, menus); + + // Find the trigger element's ID to locate the correct trigger token + const triggerElementId = triggerElement.getAttribute('data-id'); + + let cursorPosition = 0; + let foundTargetTrigger = false; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + // Find the specific trigger that matches our trigger element + if (isTriggerToken(token) && !foundTargetTrigger) { + // Match by ID if available, otherwise by being the first unmatched trigger + if (triggerElementId && token.id === triggerElementId) { + foundTargetTrigger = true; + cursorPosition += getTokenCursorLength(token); + + // Check if next token is the space we just inserted + const nextToken = tokens[i + 1]; + if (nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { + cursorPosition += 1; // Position after the space + break; + } + } else if (!triggerElementId) { + // Fallback: use first trigger + foundTargetTrigger = true; + cursorPosition += getTokenCursorLength(token); + + const nextToken = tokens[i + 1]; + if (nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { + cursorPosition += 1; + break; + } + } else { + // Not the target trigger, keep counting + cursorPosition += getTokenCursorLength(token); + } + } else { + cursorPosition += getTokenCursorLength(token); + } + } + + // Store position for unified restoration + editableState.cursorPositionOverride = { + cursorPosition, + paragraphId: null, + }; + editableState.skipCursorRestore = false; + + // Position cursor immediately to prevent it from jumping to position 0 + // This prevents menu from flickering open + const cursorRange = document.createRange(); + const spaceTextNode = spaceNode; + cursorRange.setStart(spaceTextNode, 1); // After the space + cursorRange.collapse(true); + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + sel.addRange(cursorRange); + } + + // Prevent cursor detection from reopening the menu + ignoreCursorDetection.current = true; + setTimeout(() => { + ignoreCursorDetection.current = false; + }, 100); + + // Trigger input event to extract tokens and update state + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + + return true; +} diff --git a/src/prompt-input/core/menu-state.ts b/src/prompt-input/core/menu-state.ts new file mode 100644 index 0000000000..6a00162df3 --- /dev/null +++ b/src/prompt-input/core/menu-state.ts @@ -0,0 +1,216 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, useRef } from 'react'; + +import { filterOptions } from '../../autosuggest/utils/utils'; +import { DropdownStatusProps } from '../../internal/components/dropdown-status/interfaces'; +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'; + +// TYPES + +export type MenuItem = (OptionDefinition | OptionGroup) & { + type?: 'parent' | 'child' | 'use-entered'; + 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; +} + +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; +} + +// MENU ITEMS + +function isMenuItemHighlightable(option?: MenuItem): boolean { + return !!option && option.type !== 'parent'; +} + +function isMenuItemInteractive(option?: MenuItem): boolean { + return !!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: isMenuItemHighlightable, + }); + + const selectHighlightedOptionWithKeyboard = () => { + const { highlightedOption } = highlightedOptionState; + if (!highlightedOption || !isMenuItemInteractive(highlightedOption)) { + return false; + } + onSelectItem(highlightedOption); + return true; + }; + + const highlightVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isMenuItemHighlightable(item)) { + highlightedOptionHandlers.setHighlightedIndexWithMouse(index); + } + }; + + const selectVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isMenuItemInteractive(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; +} + +// MENU LOAD MORE + +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') { + if (onLoadMoreItems) { + onLoadMoreItems(); + } else { + 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/core/token-operations.ts b/src/prompt-input/core/token-operations.ts new file mode 100644 index 0000000000..ae4f51cf26 --- /dev/null +++ b/src/prompt-input/core/token-operations.ts @@ -0,0 +1,415 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; +import type { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { getCursorPositionAtIndex, getTokenCursorLength } from './cursor-manager'; +import { findAllParagraphs, findElement, generateTokenId, getTokenType } from './dom-utils'; +import { detectTriggersInText } from './token-utils'; +import { + isBRElement, + isHTMLElement, + isPinnedReferenceToken, + isReferenceToken, + isTextNode, + isTextToken, + isTriggerToken, +} from './type-guards'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type UpdateSource = 'user-input' | 'external' | 'menu-selection' | 'internal'; + +export interface TokenUpdate { + tokens: PromptInputProps.InputToken[]; + source: UpdateSource; + cursorPosition?: number; +} + +export interface ShortcutsConfig { + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; +} + +export interface MenuSelectionResult { + tokens: PromptInputProps.InputToken[]; + cursorPosition: number; + insertedToken: PromptInputProps.ReferenceToken; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +// DOM EXTRACTION HELPERS + +function findOptionInMenu( + options: readonly (OptionDefinition | OptionGroup)[], + labelOrValue: string +): OptionDefinition | undefined { + for (const item of options) { + if ('options' in item) { + // It's a group, search in its options + const found = item.options?.find(opt => opt.value === labelOrValue || opt.label === labelOrValue); + if (found) { + return found; + } + } else if (item.value === labelOrValue || item.label === labelOrValue) { + // It's an option + return item; + } + } + return undefined; +} + +export function extractTokensFromDOM( + element: HTMLElement, + menus?: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + return []; + } + + // Special case: single empty paragraph = empty input + if (paragraphs.length === 1) { + const p = paragraphs[0]; + const hasOnlyTrailingBr = p.childNodes.length === 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK); + + if (hasOnlyTrailingBr) { + return []; + } + } + + const allTokens: PromptInputProps.InputToken[] = []; + + paragraphs.forEach((p, pIndex) => { + const paragraphTokens = extractTokensFromParagraph(p, menus); + + if (pIndex > 0) { + allTokens.push({ type: 'break', value: SPECIAL_CHARS.NEWLINE }); + } + + allTokens.push(...paragraphTokens); + }); + + return allTokens; +} + +function extractTokensFromParagraph( + p: HTMLElement, + menus?: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const tokens: PromptInputProps.InputToken[] = []; + let textBuffer = ''; + + const flushText = () => { + if (textBuffer) { + tokens.push({ type: 'text', value: textBuffer }); + textBuffer = ''; + } + }; + + const processNode = (node: Node) => { + if (isTextNode(node)) { + const text = (node.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (text) { + textBuffer += text; + } + } else if (isHTMLElement(node)) { + if (node.tagName === 'BR') { + return; + } + + const tokenType = getTokenType(node); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + flushText(); + const id = node.getAttribute('data-id') || generateTokenId('trigger'); + const fullText = node.textContent || ''; + + // Check if there's text before the trigger character (corruption case) + let triggerCharIndex = -1; + let triggerChar = ''; + + if (menus) { + for (const menu of menus) { + const index = fullText.indexOf(menu.trigger); + if (index >= 0 && (triggerCharIndex === -1 || index < triggerCharIndex)) { + triggerCharIndex = index; + triggerChar = menu.trigger; + } + } + } + + if (triggerCharIndex > 0) { + // Text before trigger - extract it as separate text token + const textBefore = fullText.substring(0, triggerCharIndex); + tokens.push({ type: 'text', value: textBefore }); + } + + if (triggerCharIndex >= 0) { + // Extract trigger + const value = fullText.substring(triggerCharIndex + 1); + + // Check if the value contains ANY trigger character (nested trigger) + // Find the earliest trigger character in the value + let nestedTriggerIndex = -1; + let nestedTriggerChar = ''; + + if (menus) { + for (const menu of menus) { + const index = value.indexOf(menu.trigger); + if (index > 0 && (nestedTriggerIndex === -1 || index < nestedTriggerIndex)) { + nestedTriggerIndex = index; + nestedTriggerChar = menu.trigger; + } + } + } + + if (nestedTriggerIndex > 0) { + // Split: first trigger + space + second trigger + const firstValue = value.substring(0, nestedTriggerIndex).trim(); + const afterFirst = value.substring(nestedTriggerIndex); + + // First trigger + tokens.push({ + type: 'trigger', + value: firstValue, + triggerChar, + id, + }); + + // Space before second trigger + const spaceBefore = value.substring(firstValue.length, nestedTriggerIndex); + if (spaceBefore) { + tokens.push({ type: 'text', value: spaceBefore }); + } + + // Second trigger (without the trigger char) + const secondValue = afterFirst.substring(1); + tokens.push({ + type: 'trigger', + value: secondValue, + triggerChar: nestedTriggerChar, + id: generateTokenId('trigger'), + }); + } else { + // Normal trigger, no nesting + tokens.push({ + type: 'trigger', + value, + triggerChar, + id, + }); + } + } else { + // No trigger character found - treat entire content as text + if (fullText) { + tokens.push({ type: 'text', value: fullText }); + } + } + } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + flushText(); + + const cursorSpotBefore = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + if (cursorSpotBefore) { + const beforeText = (cursorSpotBefore.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (beforeText) { + tokens.push({ type: 'text', value: beforeText }); + } + } + + // Extract label from token's text content (excluding cursor spots) + let label = ''; + for (const child of Array.from(node.childNodes)) { + if (isTextNode(child)) { + label += child.textContent || ''; + } else if (isHTMLElement(child)) { + const childType = getTokenType(child); + if (childType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && childType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + label += child.textContent || ''; + } + } + } + label = label.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), '').trim(); + + const instanceId = node.getAttribute('data-id') || ''; + const menuId = node.getAttribute('data-menu-id') || ''; + + // Look up option from menu definition using the label + let value = ''; + if (menuId && menus && label) { + const menu = menus.find(m => m.id === menuId); + if (menu) { + const option = findOptionInMenu(menu.options, label); + if (option) { + value = option.value || ''; + label = option.label || option.value || label; + } + } + } + + const token: PromptInputProps.ReferenceToken = { + type: 'reference', + id: instanceId, + value, + label, + menuId, + }; + if (tokenType === ELEMENT_TYPES.PINNED) { + token.pinned = true; + } + + // Only add reference token if it has a label (skip empty/corrupted tokens) + if (label) { + tokens.push(token); + } + + const cursorSpotAfter = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); + if (cursorSpotAfter) { + const afterText = (cursorSpotAfter.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (afterText) { + tokens.push({ type: 'text', value: afterText }); + } + } + } else { + Array.from(node.childNodes).forEach(processNode); + } + } + }; + + Array.from(p.childNodes).forEach(processNode); + flushText(); + + return tokens; +} + +export function getPromptText(tokens: readonly PromptInputProps.InputToken[]): string { + return tokens + .map(token => { + if (isTriggerToken(token)) { + return token.triggerChar + token.value; + } + return token.value; + }) + .join(''); +} + +export function findLastPinnedTokenIndex(tokens: readonly PromptInputProps.InputToken[]): number { + for (let i = tokens.length - 1; i >= 0; i--) { + if (isPinnedReferenceToken(tokens[i])) { + return i; + } + } + return -1; +} + +// ============================================================================ +// TRIGGER DETECTION (text-based) +// ============================================================================ + +export { detectTriggersInText } from './token-utils'; + +export function detectTriggersInTokens( + tokens: readonly PromptInputProps.InputToken[], + menus: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const result: PromptInputProps.InputToken[] = []; + + for (const token of tokens) { + if (isTextToken(token)) { + const detectedTokens = detectTriggersInText(token.value, menus, result); + result.push(...detectedTokens); + } else { + result.push(token); + } + } + + return result; +} + +// ============================================================================ +// MENU SELECTION +// ============================================================================ + +export function handleMenuSelection( + tokens: readonly PromptInputProps.InputToken[], + selectedOption: { + value: string; + label?: string; + }, + menuId: string, + isPinned: boolean, + activeTrigger: PromptInputProps.TriggerToken +): MenuSelectionResult { + const newTokens = [...tokens]; + const triggerIndex = newTokens.findIndex(t => isTriggerToken(t) && t.id === activeTrigger.id); + + if (isPinned) { + const pinnedToken: PromptInputProps.ReferenceToken = { + type: 'reference', + id: generateTokenId('ref'), + label: selectedOption.label || selectedOption.value || '', + value: selectedOption.value || '', + menuId, + pinned: true, + }; + + newTokens.splice(triggerIndex, 1); + + let insertIndex = 0; + while (insertIndex < newTokens.length && isPinnedReferenceToken(newTokens[insertIndex])) { + insertIndex++; + } + + newTokens.splice(insertIndex, 0, pinnedToken); + const cursorPos = getCursorPositionAtIndex(newTokens, insertIndex); + return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: pinnedToken }; + } else { + const referenceToken: PromptInputProps.ReferenceToken = { + type: 'reference', + id: generateTokenId('ref'), + label: selectedOption.label || selectedOption.value || '', + value: selectedOption.value || '', + menuId, + }; + + newTokens.splice(triggerIndex, 1, referenceToken); + + let cursorPos = 0; + for (const token of newTokens) { + cursorPos += getTokenCursorLength(token); + if (isReferenceToken(token) && token.id === selectedOption.value) { + break; + } + } + + return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: referenceToken }; + } +} + +// ============================================================================ +// TOKEN PROCESSING +// ============================================================================ + +export function processTokens( + tokens: readonly PromptInputProps.InputToken[], + config: ShortcutsConfig, + options: { + source: UpdateSource; + detectTriggers?: boolean; + } +): PromptInputProps.InputToken[] { + let result = [...tokens]; + + if (options.detectTriggers && config.menus) { + result = detectTriggersInTokens(result, config.menus); + } + + return result; +} diff --git a/src/prompt-input/core/token-renderer.tsx b/src/prompt-input/core/token-renderer.tsx new file mode 100644 index 0000000000..208b5e1187 --- /dev/null +++ b/src/prompt-input/core/token-renderer.tsx @@ -0,0 +1,405 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { createRoot, Root } from '../../internal/vendor/react-dom-client-stub'; +import Token from '../../token/internal'; +import { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { + createParagraph, + createTrailingBreak, + findAllParagraphs, + findElement, + findElements, + generateTokenId, + getTokenType, + insertAfter, +} from './dom-utils'; +import { isBreakToken, isBRElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; + +import styles from '../styles.css.js'; + +// REACT COMPONENT MANAGEMENT + +const rootsMap = new Map(); + +function renderComponent(element: React.ReactElement, container: HTMLElement): void { + let root = rootsMap.get(container); + if (!root) { + root = createRoot(container); + rootsMap.set(container, root); + } + + queueMicrotask(() => { + root!.render(element); + }); +} + +export function unmountComponent(container: HTMLElement): void { + const root = rootsMap.get(container); + if (root) { + root.unmount(); + rootsMap.delete(container); + } +} + +// DOM NORMALIZATION + +function normalizeParagraphsAfterRender(element: HTMLElement): void { + const paragraphs = findAllParagraphs(element); + + paragraphs.forEach(p => { + moveCursorSpotContentToParagraph(p); + removeLeadingBrowserBRs(p); + removeOrphanedZWNJ(p); + ensureEmptyParagraphsHaveTrailingBR(p); + removeTrailingBRFromStart(p); + removeMiddleTrailingBRs(p); + ensureCursorSpotsInWrappers(p); + ensureWrappersHaveAllParts(p); + ensureCursorSpotsHaveZWNJ(p); + }); +} + +function removeLeadingBrowserBRs(p: HTMLElement): void { + while (isBRElement(p.firstChild)) { + p.firstChild.remove(); + } +} + +function removeOrphanedZWNJ(p: HTMLElement): void { + Array.from(p.childNodes).forEach(node => { + if (isTextNode(node) && node.textContent === SPECIAL_CHARS.ZWNJ) { + node.remove(); + } + }); +} + +function ensureEmptyParagraphsHaveTrailingBR(p: HTMLElement): void { + if (p.childNodes.length === 0) { + p.appendChild(createTrailingBreak()); + } else if (p.childNodes.length === 1 && isTextNode(p.firstChild) && !p.firstChild.textContent?.trim()) { + p.innerHTML = ''; + p.appendChild(createTrailingBreak()); + } +} + +function removeTrailingBRFromStart(p: HTMLElement): void { + if (p.childNodes.length > 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK)) { + p.firstChild.remove(); + } +} + +function removeMiddleTrailingBRs(p: HTMLElement): void { + const children = Array.from(p.childNodes); + for (let i = 0; i < children.length - 1; i++) { + const child = children[i]; + if (isBRElement(child, ELEMENT_TYPES.TRAILING_BREAK)) { + child.remove(); + } + } +} + +function ensureCursorSpotsInWrappers(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }).forEach( + cursorSpot => { + const parent = cursorSpot.parentElement; + const parentType = parent ? getTokenType(parent) : null; + if (!parent || (parentType !== ELEMENT_TYPES.REFERENCE && parentType !== ELEMENT_TYPES.PINNED)) { + cursorSpot.remove(); + } + } + ); +} + +function ensureWrappersHaveAllParts(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.REFERENCE, ELEMENT_TYPES.PINNED] }).forEach(wrapper => { + const cursorSpotBefore = findElement(wrapper, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + const cursorSpotAfter = findElement(wrapper, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); + + if (!cursorSpotBefore || !cursorSpotAfter) { + wrapper.remove(); + } + }); +} + +function ensureCursorSpotsHaveZWNJ(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }).forEach( + cursorSpot => { + cursorSpot.innerHTML = ''; + cursorSpot.appendChild(document.createTextNode(SPECIAL_CHARS.ZWNJ)); + } + ); +} + +function moveCursorSpotContentToParagraph(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }).forEach( + cursorSpot => { + const wrapper = cursorSpot.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (!wrapper || (wrapperType !== ELEMENT_TYPES.REFERENCE && wrapperType !== ELEMENT_TYPES.PINNED)) { + return; + } + + const text = (cursorSpot.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (!text) { + return; + } + + const isBefore = cursorSpot.getAttribute('data-type') === ELEMENT_TYPES.CURSOR_SPOT_BEFORE; + const textNode = document.createTextNode(text); + + if (isBefore) { + wrapper.parentElement?.insertBefore(textNode, wrapper); + } else { + insertAfter(textNode, wrapper); + } + + cursorSpot.textContent = SPECIAL_CHARS.ZWNJ; + + // Cursor positioning is handled by unified restoration system in use-editable-tokens + } + ); +} + +// TOKEN GROUPING + +interface ParagraphGroup { + tokens: PromptInputProps.InputToken[]; +} + +function groupTokensIntoParagraphs(tokens: readonly PromptInputProps.InputToken[]): ParagraphGroup[] { + if (tokens.length === 0) { + return [{ tokens: [] }]; + } + + const paragraphs: ParagraphGroup[] = []; + let currentParagraph: PromptInputProps.InputToken[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (isBreakToken(token)) { + // Check if this is a leading break (at start or after other breaks) + const isLeadingBreak = currentParagraph.length === 0; + + if (isLeadingBreak) { + // Leading break = create empty paragraph + paragraphs.push({ tokens: [] }); + } else { + // Break after content = end current paragraph + paragraphs.push({ tokens: currentParagraph }); + currentParagraph = []; + } + } else { + // Non-break token = add to current paragraph + currentParagraph.push(token); + } + } + + // Add final paragraph (always - could be empty from trailing break or have content) + paragraphs.push({ tokens: currentParagraph }); + + return paragraphs; +} + +// CURSOR SPOT CREATION +function createCursorSpot(type: string): HTMLSpanElement { + const cursorSpot = document.createElement('span'); + cursorSpot.setAttribute('data-type', type); + cursorSpot.setAttribute('contenteditable', 'true'); + cursorSpot.setAttribute('aria-hidden', 'true'); + cursorSpot.appendChild(document.createTextNode(SPECIAL_CHARS.ZWNJ)); + return cursorSpot; +} + +function createReferenceWithCursorSpots( + token: PromptInputProps.ReferenceToken, + reactContainers: Set, + disabled: boolean, + readOnly: boolean +): HTMLSpanElement { + const wrapper = document.createElement('span'); + wrapper.setAttribute('data-type', token.pinned ? ELEMENT_TYPES.PINNED : ELEMENT_TYPES.REFERENCE); + const instanceId = token.id || generateTokenId('ref'); + wrapper.setAttribute('data-id', instanceId); + wrapper.setAttribute('data-menu-id', token.menuId); + + const cursorSpotBefore = createCursorSpot(ELEMENT_TYPES.CURSOR_SPOT_BEFORE); + const container = document.createElement('span'); + container.className = styles['token-container']; + container.setAttribute('contenteditable', 'false'); + + reactContainers.add(container); + renderComponent( + , + container + ); + const cursorSpotAfter = createCursorSpot(ELEMENT_TYPES.CURSOR_SPOT_AFTER); + + wrapper.appendChild(cursorSpotBefore); + wrapper.appendChild(container); + wrapper.appendChild(cursorSpotAfter); + + return wrapper; +} + +// MAIN RENDERING + +export function renderTokensToDOM( + tokens: readonly PromptInputProps.InputToken[], + targetElement: HTMLElement, + reactContainers: Set, + options?: { + disabled?: boolean; + readOnly?: boolean; + } +): { + newTriggerElement: HTMLElement | null; + lastReferenceWithZwnj: HTMLElement | null; +} { + const { disabled = false, readOnly = false } = options || {}; + const existingContainers = new Map(); + reactContainers.forEach(container => { + const instanceId = container.getAttribute('data-id'); + if (instanceId && container.isConnected) { + existingContainers.set(instanceId, container); + } + }); + reactContainers.clear(); + + // Track existing trigger elements to reuse them + const existingTriggers = new Map(); + findElements(targetElement, { tokenType: ELEMENT_TYPES.TRIGGER }).forEach(el => { + const id = el.getAttribute('data-id'); + if (id) { + existingTriggers.set(id, el); + } + }); + + const existingParagraphs = findAllParagraphs(targetElement); + const paragraphGroups = groupTokensIntoParagraphs(tokens); + + let newTriggerElement: HTMLElement | null = null; + let lastReferenceWithZwnj: HTMLElement | null = null; + + for (let pIndex = 0; pIndex < paragraphGroups.length; pIndex++) { + const paragraphGroup = paragraphGroups[pIndex]; + let p: HTMLParagraphElement; + + if (pIndex < existingParagraphs.length) { + p = existingParagraphs[pIndex]; + // Don't clear innerHTML - we'll do selective updates below + } else { + p = createParagraph(); + targetElement.appendChild(p); + } + + // Build new content for this paragraph + const newNodes: Node[] = []; + + for (let i = 0; i < paragraphGroup.tokens.length; i++) { + const token = paragraphGroup.tokens[i]; + + if (isTextToken(token)) { + if (token.value) { + newNodes.push(document.createTextNode(token.value)); + } + } else if (isTriggerToken(token)) { + let span: HTMLElement; + const isNewTrigger = !token.id || !existingTriggers.has(token.id); + const hasFilterText = token.value.length > 0; + + if (token.id && existingTriggers.has(token.id)) { + // Reuse existing trigger element and update its content + span = existingTriggers.get(token.id)!; + span.textContent = token.triggerChar + token.value; + span.className = hasFilterText ? styles['trigger-token'] : ''; + existingTriggers.delete(token.id); + } else { + // Create new trigger element + span = document.createElement('span'); + span.setAttribute('data-type', ELEMENT_TYPES.TRIGGER); + span.className = hasFilterText ? styles['trigger-token'] : ''; + if (token.id) { + span.setAttribute('data-id', token.id); + } + span.textContent = token.triggerChar + token.value; + } + + newNodes.push(span); + + if (isNewTrigger) { + newTriggerElement = span; + } + } else if (isReferenceToken(token)) { + const existingWrapper = token.id ? existingContainers.get(token.id) : undefined; + if (existingWrapper) { + const tokenType = getTokenType(existingWrapper); + if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + // Reuse existing wrapper - token props never change + newNodes.push(existingWrapper); + reactContainers.add(existingWrapper); + existingContainers.delete(token.id!); + lastReferenceWithZwnj = existingWrapper; + continue; + } + } + + const wrapper = createReferenceWithCursorSpots(token, reactContainers, disabled, readOnly); + newNodes.push(wrapper); + lastReferenceWithZwnj = wrapper; + } + } + + if (newNodes.length === 0) { + newNodes.push(createTrailingBreak()); + } + + // Efficiently update paragraph children by comparing with existing nodes + const existingNodes = Array.from(p.childNodes); + + // Remove nodes that are no longer needed + for (let i = newNodes.length; i < existingNodes.length; i++) { + existingNodes[i].remove(); + } + + // Update or append nodes + for (let i = 0; i < newNodes.length; i++) { + const newNode = newNodes[i]; + const existingNode = existingNodes[i]; + + if (existingNode === newNode) { + // Node is already in the right position, skip + continue; + } + + // Check if existingNode was moved (is now in newNodes at a different position) + if (existingNode && newNodes.includes(existingNode)) { + // Don't replace - the existing node was moved elsewhere + // Just append the new node + if (i < p.childNodes.length) { + p.insertBefore(newNode, p.childNodes[i]); + } else { + p.appendChild(newNode); + } + } else if (existingNode) { + // Replace existing node with new node + p.replaceChild(newNode, existingNode); + } else { + // Append new node + p.appendChild(newNode); + } + } + } + + while (targetElement.children.length > paragraphGroups.length) { + targetElement.removeChild(targetElement.lastChild!); + } + + normalizeParagraphsAfterRender(targetElement); + + return { newTriggerElement, lastReferenceWithZwnj }; +} diff --git a/src/prompt-input/core/token-utils.ts b/src/prompt-input/core/token-utils.ts new file mode 100644 index 0000000000..5522a9e974 --- /dev/null +++ b/src/prompt-input/core/token-utils.ts @@ -0,0 +1,342 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES } from './constants'; +import { getTokenCursorLength, setCursorPosition } from './cursor-manager'; +import { applySafariCursorFix, setCursorOverride } from './cursor-utils'; +import { findAllParagraphs, generateTokenId, getTokenType } from './dom-utils'; +import { getPromptText } from './token-operations'; +import { isBreakToken, isHTMLElement, isPinnedReferenceToken, isTextNode } from './type-guards'; + +function findLastPinnedTokenIndex(tokens: readonly PromptInputProps.InputToken[]): number { + for (let i = tokens.length - 1; i >= 0; i--) { + if (isPinnedReferenceToken(tokens[i])) { + return i; + } + } + return -1; +} + +export function enforcePinnedTokenOrdering( + tokens: readonly PromptInputProps.InputToken[] +): PromptInputProps.InputToken[] { + const lastPinnedIndex = findLastPinnedTokenIndex(tokens); + + if (lastPinnedIndex === -1) { + return [...tokens]; + } + + const pinnedTokens: PromptInputProps.InputToken[] = []; + const forbiddenContent: PromptInputProps.InputToken[] = []; + const allowedContent: PromptInputProps.InputToken[] = []; + + tokens.forEach((token, index) => { + if (isPinnedReferenceToken(token)) { + pinnedTokens.push(token); + } else if (index <= lastPinnedIndex) { + forbiddenContent.push(token); + } else { + allowedContent.push(token); + } + }); + + return [...pinnedTokens, ...forbiddenContent, ...allowedContent]; +} + +export function canDeleteToken(token: PromptInputProps.InputToken): boolean { + return !isPinnedReferenceToken(token); +} + +export function areAllTokensPinned(tokens: readonly PromptInputProps.InputToken[]): boolean { + return tokens.every(isPinnedReferenceToken); +} + +export function validateTriggerWithPinnedTokens( + menu: PromptInputProps.MenuDefinition, + precedingTokens: readonly PromptInputProps.InputToken[] +): boolean { + if (menu.useAtStart) { + return areAllTokensPinned(precedingTokens); + } + return true; +} + +export function validateTrigger( + menu: PromptInputProps.MenuDefinition, + triggerIndex: number, + text: string, + precedingTokens: readonly PromptInputProps.InputToken[] +): boolean { + const isAtStart = triggerIndex === 0; + const charBefore = triggerIndex > 0 ? text[triggerIndex - 1] : ''; + const isAfterWhitespace = /\s/.test(charBefore); + + if (menu.useAtStart) { + return isAtStart && areAllTokensPinned(precedingTokens); + } + + return isAtStart || isAfterWhitespace; +} + +export function detectTriggersInText( + text: string, + menus: readonly PromptInputProps.MenuDefinition[], + precedingTokens: readonly PromptInputProps.InputToken[] +): PromptInputProps.InputToken[] { + const results: PromptInputProps.InputToken[] = []; + let position = 0; + + while (position < text.length) { + let foundTrigger = false; + + for (const menu of menus) { + const triggerIndex = text.indexOf(menu.trigger, position); + if (triggerIndex === -1) { + continue; + } + + if (!validateTrigger(menu, triggerIndex, text, precedingTokens)) { + continue; + } + + const beforeTrigger = text.substring(position, triggerIndex); + if (beforeTrigger) { + results.push({ type: 'text', value: beforeTrigger }); + } + + const afterTrigger = text.substring(triggerIndex + menu.trigger.length); + let filterText = ''; + let remainingText = afterTrigger; + + if (afterTrigger && !/^\s/.test(afterTrigger)) { + let endIndex = 0; + while (endIndex < afterTrigger.length && !/\s/.test(afterTrigger[endIndex])) { + endIndex++; + } + filterText = afterTrigger.substring(0, endIndex); + remainingText = afterTrigger.substring(endIndex); + } + + results.push({ + type: 'trigger', + value: filterText, + triggerChar: menu.trigger, + id: generateTokenId('trigger'), + }); + + if (remainingText) { + results.push({ type: 'text', value: remainingText }); + } + + position = text.length; // Move to end to exit while loop + foundTrigger = true; + break; + } + + if (!foundTrigger) { + const remainingText = text.substring(position); + if (remainingText) { + results.push({ type: 'text', value: remainingText }); + } + break; + } + } + + return results.length > 0 ? results : [{ type: 'text', value: text }]; +} + +export type ArrowDirection = 'left' | 'right'; + +export interface AdjacentTokenResult { + sibling: Node | null; + isReferenceToken: boolean; +} + +export function findAdjacentToken(container: Node, offset: number, direction: ArrowDirection): AdjacentTokenResult { + let sibling: Node | null = null; + + if (isTextNode(container)) { + const isAtBoundary = direction === 'left' ? offset === 0 : offset === (container.textContent?.length || 0); + + if (isAtBoundary) { + sibling = direction === 'left' ? container.previousSibling : container.nextSibling; + } + } else if (isHTMLElement(container)) { + if (direction === 'left') { + sibling = offset > 0 ? container.childNodes[offset - 1] : container.previousSibling; + } else { + sibling = offset < container.childNodes.length ? container.childNodes[offset] : container.nextSibling; + } + } + + const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; + const isReferenceToken = siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED; + + return { sibling, isReferenceToken }; +} + +export type MergeDirection = 'forward' | 'backward'; + +interface MergeParagraphsParams { + direction: MergeDirection; + editableElement: HTMLDivElement; + tokens: readonly PromptInputProps.InputToken[]; + currentParagraphIndex: number; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + state?: EditableState; +} + +export function mergeParagraphs(params: MergeParagraphsParams): boolean { + const { direction, editableElement, tokens, currentParagraphIndex, tokensToText, onChange, state } = params; + + const paragraphs = findAllParagraphs(editableElement); + + if (direction === 'backward') { + if (currentParagraphIndex <= 0) { + return false; + } + } else { + if (currentParagraphIndex >= paragraphs.length - 1) { + return false; + } + } + + const breakIndexToRemove = direction === 'backward' ? currentParagraphIndex : currentParagraphIndex + 1; + + let breakCount = 0; + let cursorPosition = 0; + + const newTokens = tokens.filter(token => { + if (isBreakToken(token)) { + breakCount++; + if (breakCount === breakIndexToRemove) { + return false; + } + cursorPosition += 1; + } else { + if (breakCount < breakIndexToRemove) { + cursorPosition += getTokenCursorLength(token); + } + } + return true; + }); + + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + onChange({ value, tokens: newTokens }); + + if (state) { + setCursorOverride(state, cursorPosition); + state.isDeleteOperation = true; + + // Apply Safari cursor fix immediately for line deletions + applySafariCursorFix(editableElement, state, cursorPosition); + } else { + requestAnimationFrame(() => { + setCursorPosition(editableElement, cursorPosition); + }); + } + + return true; +} + +export function handleBackspaceAtParagraphStart( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + tokens: readonly PromptInputProps.InputToken[], + tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + state?: EditableState +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + if (range.startOffset !== 0 || range.startContainer.nodeName !== 'P') { + return false; + } + + const paragraphs = findAllParagraphs(editableElement); + const currentP = range.startContainer; + const pIndex = Array.from(paragraphs).indexOf(currentP as HTMLParagraphElement); + + if (pIndex < 0) { + return false; + } + + event.preventDefault(); + + return mergeParagraphs({ + direction: 'backward', + editableElement, + tokens, + currentParagraphIndex: pIndex, + tokensToText, + onChange, + state, + }); +} + +export function handleDeleteAtParagraphEnd( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + tokens: readonly PromptInputProps.InputToken[], + tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, + cursorPosition: number, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + state?: EditableState +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + const container = range.startContainer; + + let isAtEndOfParagraph = false; + let currentP: HTMLParagraphElement | null = null; + + if (container.nodeName === 'P') { + currentP = container as HTMLParagraphElement; + const hasOnlyTrailingBR = currentP.childNodes.length === 1 && currentP.firstChild?.nodeName === 'BR'; + isAtEndOfParagraph = hasOnlyTrailingBR || range.startOffset === currentP.childNodes.length; + } else if (container.nodeType === Node.TEXT_NODE) { + isAtEndOfParagraph = range.startOffset === (container.textContent?.length || 0) && !container.nextSibling; + let node: Node | null = container; + while (node && node.nodeName !== 'P') { + node = node.parentNode; + } + currentP = node as HTMLParagraphElement; + } + + if (!isAtEndOfParagraph || !currentP) { + return false; + } + + const paragraphs = findAllParagraphs(editableElement); + const pIndex = Array.from(paragraphs).indexOf(currentP); + + if (pIndex < 0) { + return false; + } + + event.preventDefault(); + + return mergeParagraphs({ + direction: 'forward', + editableElement, + tokens, + currentParagraphIndex: pIndex, + tokensToText, + onChange, + state, + }); +} diff --git a/src/prompt-input/core/trigger-utils.ts b/src/prompt-input/core/trigger-utils.ts new file mode 100644 index 0000000000..fedf8e67d6 --- /dev/null +++ b/src/prompt-input/core/trigger-utils.ts @@ -0,0 +1,137 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES } from './constants'; +import { getCursorPosition, positionAfter } from './cursor-manager'; +import { getTokenType, insertAfter } from './dom-utils'; +import { MenuItemsHandlers, MenuItemsState } from './menu-state'; +import { isTextNode } from './type-guards'; + +import styles from '../styles.css.js'; + +interface TriggerSpaceHandlerProps { + menuItemsState: MenuItemsState; + menuItemsHandlers: MenuItemsHandlers; + getMenuStatusType?: () => PromptInputProps.MenuDefinition['statusType']; + closeMenu: () => void; + editableElementRef?: React.RefObject; + lastKnownCursorPositionRef?: React.MutableRefObject; + editableState?: EditableState; +} + +/** + * Finds the trigger element at the current cursor position + */ +export function findTriggerAtCursor(): HTMLElement | null { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return null; + } + + const range = selection.getRangeAt(0); + const parent = isTextNode(range.startContainer) ? range.startContainer.parentElement : null; + return parent && getTokenType(parent) === ELEMENT_TYPES.TRIGGER ? parent : null; +} + +/** + * Finalizes space insertion after a trigger by positioning cursor and updating refs + */ +function finalizeSpaceInsertion( + spaceNode: Text, + props: Pick +): void { + positionAfter(spaceNode); + + if (props.editableElementRef?.current && props.lastKnownCursorPositionRef) { + props.lastKnownCursorPositionRef.current = getCursorPosition(props.editableElementRef.current); + } + if (props.editableState) { + props.editableState.skipCursorRestore = true; + } + + queueMicrotask(() => { + const editableElement = spaceNode.parentElement?.closest('[contenteditable="true"]') as HTMLElement; + if (editableElement) { + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + } + }); +} + +/** + * Handles space key press when menu is open + * Returns true if handled, false to allow default behavior + */ +export function handleSpaceInOpenMenu(event: React.KeyboardEvent, props: TriggerSpaceHandlerProps): boolean { + const { menuItemsState, menuItemsHandlers, getMenuStatusType, closeMenu } = props; + const items = menuItemsState.items; + const statusType = getMenuStatusType?.() ?? 'finished'; + const isLoading = statusType === 'loading' || statusType === 'pending'; + + const triggerElement = findTriggerAtCursor(); + if (!triggerElement) { + return false; + } + + const triggerText = triggerElement.textContent || ''; + const triggerChar = triggerText[0]; + const filterText = triggerText.substring(1); + + // Case 1: Single selectable option (not loading) - select it + const selectableItems = items.filter(item => item.type !== 'parent'); + if (selectableItems.length === 1 && !isLoading) { + event.preventDefault(); + return menuItemsHandlers.selectHighlightedOptionWithKeyboard(); + } + + // Case 2: Double space - close menu, clean filter, add ONE space + if (filterText.endsWith(' ')) { + event.preventDefault(); + closeMenu(); + + const cleanFilterText = filterText.trimEnd(); + triggerElement.textContent = triggerChar + cleanFilterText; + triggerElement.className = cleanFilterText.length > 0 ? styles['trigger-token'] : ''; + + const oneSpace = document.createTextNode(' '); + insertAfter(oneSpace, triggerElement); + finalizeSpaceInsertion(oneSpace, props); + + return true; + } + + // Case 3: Empty filter - close menu, add space as plain text + if (filterText === '') { + event.preventDefault(); + closeMenu(); + + const spaceNode = document.createTextNode(' '); + insertAfter(spaceNode, triggerElement); + finalizeSpaceInsertion(spaceNode, props); + + return true; + } + + // Default: Allow space in filter for multi-word filtering + return false; +} + +/** + * Checks if a trigger needs immediate re-rendering due to styling changes + */ +export function needsImmediateRenderForStyling( + newTriggers: PromptInputProps.TriggerToken[], + oldTriggers: PromptInputProps.TriggerToken[] +): boolean { + return newTriggers.some((newT, i) => { + const oldT = oldTriggers[i]; + if (!oldT) { + return false; + } + // Render when transitioning between empty and non-empty filter (styling change) + const wasEmpty = oldT.value.length === 0; + const isEmpty = newT.value.length === 0; + return wasEmpty !== isEmpty; + }); +} diff --git a/src/prompt-input/core/type-guards.ts b/src/prompt-input/core/type-guards.ts new file mode 100644 index 0000000000..4fd9699a66 --- /dev/null +++ b/src/prompt-input/core/type-guards.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; + +// DOM TYPE GUARDS + +export function isHTMLElement(node: Node | null | undefined): node is HTMLElement { + return node?.nodeType === Node.ELEMENT_NODE; +} + +export function isTextNode(node: Node | null): node is Text { + return node?.nodeType === Node.TEXT_NODE; +} + +/** + * Type guard to check if a node is a BR element, optionally with a specific data-id + * @param node The node to check + * @param dataId Optional data-id to match (e.g., ELEMENT_TYPES.TRAILING_BREAK) + * @returns True if the node is a BR element (and matches the data-id if provided) + */ +export function isBRElement(node: Node | null | undefined, dataId?: string): node is HTMLBRElement { + if (node?.nodeName !== 'BR' || !isHTMLElement(node)) { + return false; + } + if (dataId !== undefined) { + return node.getAttribute('data-id') === dataId; + } + return true; +} + +// TOKEN TYPE GUARDS + +export function isTextToken(token: PromptInputProps.InputToken): token is PromptInputProps.TextToken { + return token.type === 'text'; +} + +export function isBreakToken(token: PromptInputProps.InputToken): token is PromptInputProps.TextToken { + return token.type === 'break'; +} + +export function isTriggerToken(token: PromptInputProps.InputToken): token is PromptInputProps.TriggerToken { + return token.type === 'trigger'; +} + +export function isReferenceToken(token: PromptInputProps.InputToken): token is PromptInputProps.ReferenceToken { + return token.type === 'reference'; +} + +export function isPinnedReferenceToken(token: PromptInputProps.InputToken): token is PromptInputProps.ReferenceToken { + return isReferenceToken(token) && token.pinned === true; +} diff --git a/src/prompt-input/index.tsx b/src/prompt-input/index.tsx index af9857f233..462ffcaa66 100644 --- a/src/prompt-input/index.tsx +++ b/src/prompt-input/index.tsx @@ -24,7 +24,7 @@ const PromptInput = React.forwardRef( maxRows = 3, ...props }: PromptInputProps, - ref: React.Ref + ref: React.Ref ) => { const baseComponentProps = useBaseComponent('PromptInput', { props: { diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 19dab2560e..5ba443df77 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -1,15 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + import { IconProps } from '../icon/interfaces'; -import { - BaseChangeDetail, - BaseInputProps, - InputAutoComplete, - InputAutoCorrect, - InputKeyEvents, - InputSpellcheck, -} from '../input/interfaces'; +import { BaseInputProps, InputAutoCorrect, InputKeyEvents, InputSpellcheck } from '../input/interfaces'; import { BaseComponentProps } from '../internal/base-component'; +import { BaseDropdownHostProps, OptionsFilteringType } from '../internal/components/dropdown/interfaces'; +import { DropdownStatusProps } from '../internal/components/dropdown-status'; +import { OptionDefinition, OptionGroup } from '../internal/components/option/interfaces'; import { FormFieldValidationControlProps } from '../internal/context/form-field-context'; import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; /** @@ -18,28 +16,101 @@ import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps - extends Omit, + extends Omit, InputKeyEvents, InputAutoCorrect, - InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { + /** + * Specifies the name of the prompt input for form submissions. + */ + 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 `menus` is defined (token mode): + * - This property is optional and defaults to empty string + * - The actual content is managed via the `tokens` array + * - `onChange` and `onAction` events will provide the serialized text value + * + * When `menus` is not defined (text mode): + * - This property is required + * - Represents the current text content of the textarea + */ + 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') + * - Trigger tokens: `value` contains the filter text, `triggerChar` for the trigger character + * + * When `menus` is defined, you should use `tokens` to control the content instead of `value`. + * + * Requires React 18. + */ + tokens?: readonly PromptInputProps.InputToken[]; + + /** + * 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 + * + * Requires React 18. + */ + 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,17 @@ 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 action button. * @i18n + * @deprecated Use `i18nStrings.actionButtonAriaLabel` instead. */ actionButtonAriaLabel?: string; @@ -118,6 +192,75 @@ 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`. + * + * Requires React 18. + */ + menus?: PromptInputProps.MenuDefinition[]; + + /** + * Maximum height of the menu dropdown in pixels. + * When not specified, the menu will grow to fit its content. + * + * Requires React 18. + */ + maxMenuHeight?: number; + + /** + * Called whenever a user selects an option in a menu. + * + * Requires React 18. + */ + 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` (pagination). + * - 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 to use to fetch options (undefined for pagination). + * - `firstPage` - Indicates that you should fetch the first page of options. + * - `samePage` - Indicates that you should fetch the same page (for example, when clicking recovery button). + * + * Requires React 18. + */ + onMenuLoadItems?: 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. + * + * Requires React 18. + */ + 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; + /** * Attributes to add to the native `textarea` element. * Some attributes will be automatically combined with internal attribute values: @@ -125,14 +268,13 @@ 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 */ nativeTextareaAttributes?: NativeAttributes>; /** - * An object containing CSS properties to customize the prompt input's visual appearance. - * Refer to the [style](/components/prompt-input/?tabId=style) tab for more details. * @awsuiSystem core */ style?: PromptInputProps.Style; @@ -140,7 +282,180 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; - export type ActionDetail = BaseChangeDetail; + + export interface I18nStrings { + actionButtonAriaLabel?: string; + menuErrorIconAriaLabel?: string; + menuRecoveryText?: string; + menuLoadingText?: string; + menuFinishedText?: string; + menuErrorText?: string; + /** + * Aria label announced when a reference token is inserted from a menu. + * Receives the token object with label and value properties. + * @param token The inserted token + * @returns The announcement string + * @default `${token.label || token.value} inserted` + */ + tokenInsertedAriaLabel?: (token: { label?: string; value: string }) => string; + /** + * Aria label announced when a reference token is pinned (inserted at the start). + * Receives the token object with label and value properties. + * @param token The pinned token + * @returns The announcement string + * @default `${token.label || token.value} pinned` + */ + tokenPinnedAriaLabel?: (token: { label?: string; value: string }) => string; + /** + * Aria label announced when a reference token is removed. + * Receives the token object with label and value properties. + * @param token The removed token + * @returns The announcement string + * @default `${token.label || token.value} removed` + */ + tokenRemovedAriaLabel?: (token: { label?: string; value: string }) => string; + } + + export interface TextToken { + type: 'text' | 'break'; + value: string; + } + + export interface ReferenceToken { + type: 'reference'; + id: string; + label: string; + value: string; + menuId: string; + /** + * When true, prevents user entered text from being placed before this token. + * Typically set for reference tokens from useAtStart menus. + */ + pinned?: boolean; + } + + /** + * Token type for active menu triggers with filter text. + * Represents a trigger character (e.g., "@" or "/") followed by filtering text. + * This token type is automatically managed by the component when menus are active. + * + * - `value`: The filtering text (without the trigger character) + * - `triggerChar`: The trigger character that opened the menu + */ + export interface TriggerToken { + type: 'trigger'; + value: string; + triggerChar: string; + /** + * Internal: Unique ID for this specific trigger token instance. + * Used to anchor menus to the correct trigger when multiple triggers exist. + */ + id?: string; + } + + export type InputToken = TextToken | ReferenceToken | TriggerToken; + + 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; // Optional - undefined for pagination (load more) + firstPage: boolean; + samePage: boolean; + } + + 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 pinned reference tokens. + */ + 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 | OptionGroup)[]; + + /** + * 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 { /** @@ -161,6 +476,15 @@ 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 a specified position. Triggers input events and menu detection when `menus` is defined. + * + * @param text The text to insert. + * @param cursorStart Position to insert at. Defaults to end of content. + * @param cursorEnd Cursor position after insertion. Defaults to end of inserted text. + */ + insertText(text: string, cursorStart?: number, cursorEnd?: number): void; } export interface Style { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index aa76b69f98..4a44e0b3e8 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -1,22 +1,44 @@ // 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, { Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; +import { useDensityMode, useStableCallback, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; import { convertAutoComplete } from '../input/utils'; import { getBaseProps } from '../internal/base-component'; +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 InternalLiveRegion from '../live-region/internal'; +import TextareaMode from './components/textarea-mode'; +import TokenMode from './components/token-mode'; +import { CURSOR_DETECTION_DELAY, DEFAULT_MAX_ROWS, NEXT_TICK_TIMEOUT } from './core/constants'; +import { isCursorInTriggerToken, setCursorPosition, setCursorRange } from './core/cursor-manager'; +import { normalizeSelection, selectAllContent } from './core/cursor-utils'; +import { + createCursorNormalizationHandler, + createKeyboardHandlers, + handleArrowKeyNavigation, + handleReferenceTokenDeletion, + handleSpaceAfterClosedTrigger, + splitParagraphAtCursor, +} from './core/event-handlers'; +import { MenuItem, useMenuItems } from './core/menu-state'; +import { useMenuLoadMore } from './core/menu-state'; +import { handleMenuSelection } from './core/token-operations'; +import { getPromptText } from './core/token-operations'; +import { handleBackspaceAtParagraphStart, handleDeleteAtParagraphEnd } from './core/token-utils'; import { PromptInputProps } from './interfaces'; +import { useShortcuts } from './shortcuts/use-shortcuts'; import { getPromptInputStyles } from './styles'; +import { createEditableState, useEditableTokens } from './tokens/use-editable-tokens'; +import { insertTextIntoContentEditable } from './utils/insert-text-content-editable'; import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; @@ -28,15 +50,15 @@ interface InternalPromptInputProps const InternalPromptInput = React.forwardRef( ( { - value, + value: valueProp, actionButtonAriaLabel, actionButtonIconName, actionButtonIconUrl, actionButtonIconSvg, actionButtonIconAlt, ariaLabel, - autoComplete, autoFocus, + autoComplete, disableActionButton, disableBrowserAutocorrect, disabled, @@ -59,41 +81,299 @@ const InternalPromptInput = React.forwardRef( disableSecondaryContentPaddings, nativeTextareaAttributes, style, + tokens, + tokensToText, + menus, + maxMenuHeight, + onMenuItemSelect, + onMenuFilter, + onMenuLoadItems, + i18nStrings, __internalRootRef, ...rest }: InternalPromptInputProps, - ref: 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; + + // Mode detection - must be declared before useEffect hooks that use it + const isTokenMode = !!menus; + + // Default value based on mode + const value = valueProp ?? (isTokenMode ? '' : ''); + + // Refs const textareaRef = useRef(null); + const editableElementRef = useRef(null); + const reactContainersRef = useRef>(new Set()); + const lastKnownCursorPositionRef = useRef(0); + + // Initialize consolidated shortcuts system + const shortcuts = useShortcuts({ + isTokenMode, + tokens, + menus, + tokensToText, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { + fireNonCancelableEvent(onChange, detail); + }, + editableElementRef, + }); + + // Extract shortcuts state for easier access + const { + ignoreCursorDetection, + activeTriggerToken, + activeMenu, + menuIsOpen, + menuFilterText, + triggerWrapperRef, + triggerWrapperReady, + processUserInput, + markTokensAsSent, + setUpdateSource, + } = shortcuts; + // Mode detection const isRefresh = useVisualRefresh(); - const isCompactMode = useDensityMode(textareaRef) === 'compact'; + useDensityMode(textareaRef); + useDensityMode(editableElementRef); + + // Style constants + const PADDING = isRefresh ? designTokens.spaceXxs : designTokens.spaceXxxs; + const LINE_HEIGHT = designTokens.lineHeightBodyM; + + // Helper to get the active input element + const getActiveElement = useStableCallback(() => { + return isTokenMode ? editableElementRef.current : textareaRef.current; + }); - const PADDING = isRefresh ? tokens.spaceXxs : tokens.spaceXxxs; - const LINE_HEIGHT = tokens.lineHeightBodyM; - const DEFAULT_MAX_ROWS = 3; + // Create editable state for coordinating between event handlers and input processing + const editableState = useMemo(() => createEditableState(), []); useImperativeHandle( ref, () => ({ focus(...args: Parameters) { - textareaRef.current?.focus(...args); + getActiveElement()?.focus(...args); }, select() { - textareaRef.current?.select(); + if (isTokenMode) { + if (editableElementRef.current) { + selectAllContent(editableElementRef.current); + } + } else { + textareaRef.current?.select(); + } }, setSelectionRange(...args: Parameters) { - textareaRef.current?.setSelectionRange(...args); + if (isTokenMode && editableElementRef.current) { + const [start, end] = args; + + if (end !== undefined && end !== null && end !== start) { + setCursorRange(editableElementRef.current, start ?? 0, end); + } else { + setCursorPosition(editableElementRef.current, start ?? 0); + } + } else { + textareaRef.current?.setSelectionRange(...args); + } + }, + insertText(text: string, cursorStart?: number, cursorEnd?: number) { + // Guard against disabled/readonly at the ref level + if (disabled || readOnly) { + return; + } + + if (isTokenMode) { + if (!editableElementRef.current || !tokens) { + return; + } + + // Calculate offset for pinned references + // Pinned references are always at the start and can't have content inserted before/between them + const pinnedTokens = tokens.filter( + (token): token is PromptInputProps.ReferenceToken => token.type === 'reference' && token.pinned === true + ); + const pinnedOffset = pinnedTokens.length; + + // Adjust cursor positions to account for pinned tokens + const adjustedCursorStart = cursorStart !== undefined ? cursorStart + pinnedOffset : undefined; + const adjustedCursorEnd = cursorEnd !== undefined ? cursorEnd + pinnedOffset : undefined; + + insertTextIntoContentEditable(editableElementRef.current, text, adjustedCursorStart, adjustedCursorEnd); + } else { + // Textarea mode + if (!textareaRef.current) { + return; + } + + const textarea = textareaRef.current; + textarea.focus(); + + const currentValue = textarea.value; + const insertPosition = cursorStart ?? textarea.selectionStart ?? 0; + const newValue = currentValue.substring(0, insertPosition) + text + currentValue.substring(insertPosition); + + textarea.value = newValue; + + const finalCursorPosition = cursorEnd ?? insertPosition + text.length; + textarea.setSelectionRange(finalCursorPosition, finalCursorPosition); + + textarea.dispatchEvent(new Event('input', { bubbles: true })); + fireNonCancelableEvent(onChange, { + value: newValue, + }); + } }, }), - [textareaRef] + // eslint-disable-next-line react-hooks/exhaustive-deps + [getActiveElement, isTokenMode, disabled, readOnly] ); - const handleKeyDown = (event: React.KeyboardEvent) => { + /** + * Dynamically adjusts the input height based on content and row constraints. + */ + const adjustInputHeight = useStableCallback(() => { + const element = getActiveElement(); + 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 ? DEFAULT_MAX_ROWS : maxRows; + const maxRowsHeight = `calc(${effectiveMaxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; + element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; + } + + if (isTokenMode) { + element.scrollTop = scrollTop; + } + }); + + // Adjust height when tokens change (after DOM updates) + useEffect(() => { + if (isTokenMode) { + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => adjustInputHeight()); + } else { + adjustInputHeight(); + } + }, [isTokenMode, tokens, adjustInputHeight, value]); + + // Helper to get plain text value from tokens or value prop + const getPlainTextValue = useStableCallback(() => { + if (isTokenMode) { + return tokensToText ? tokensToText(tokens ?? []) : getPromptText(tokens ?? []); + } + return value; + }); + + // Use the editable hook as interface layer between contentEditable DOM and React + const { handleInput: handleInputBase } = useEditableTokens({ + elementRef: editableElementRef, + reactContainersRef, + tokens, + menus, + tokensToText, + onChange: detail => { + processUserInput(detail.tokens); + }, + adjustInputHeight, + disabled: disabled || !isTokenMode, + readOnly, + editableState, + ignoreCursorDetection, + lastKnownCursorPositionRef, + }); + + const handleInput = handleInputBase; + + // Track if we're in the middle of arrow key navigation to avoid cursor trapping + const skipNormalizationRef = React.useRef(false); + + // Normalize cursor position: if cursor is right after a wrapper, move it into the cursor spot + React.useEffect(() => { + if (!isTokenMode || !editableElementRef.current) { + return; + } + + const normalizeCursorPosition = createCursorNormalizationHandler( + editableElementRef, + skipNormalizationRef, + editableState + ); + + // Track mouse state to skip normalization during/after mouse clicks + const handleMouseDown = () => { + window.isMouseDownForCursor = true; + }; + const handleMouseUp = () => { + // Delay clearing the flag to allow the click to complete + setTimeout(() => { + window.isMouseDownForCursor = false; + }, 100); + }; + + const normalizeIfNotMouse = () => { + if (!window.isMouseDownForCursor) { + normalizeCursorPosition(); + } + }; + + document.addEventListener('selectionchange', normalizeIfNotMouse); + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('selectionchange', normalizeIfNotMouse); + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isTokenMode, editableState]); + + // Normalize selection to include entire reference tokens when boundary is in cursor spots + React.useEffect(() => { + if (!isTokenMode) { + return; + } + + const handleSelectionChange = () => normalizeSelection(window.getSelection()); + const handleMouseDown = () => { + window.isMouseDown = true; + }; + const handleMouseUp = () => { + window.isMouseDown = false; + normalizeSelection(window.getSelection()); + }; + + document.addEventListener('selectionchange', handleSelectionChange); + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isTokenMode]); + + const handleTextareaKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { @@ -101,52 +381,364 @@ const InternalPromptInput = React.forwardRef( event.currentTarget.form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value }); + fireNonCancelableEvent(onAction, { + value: getPlainTextValue(), + ...(isTokenMode && { tokens: [...(tokens ?? [])] }), + }); } }; - const handleChange = (event: React.ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: event.target.value }); - adjustTextareaHeight(); + const handleTextareaChange = (event: React.ChangeEvent) => { + if (isTokenMode) { + markTokensAsSent([...(tokens ?? [])]); + } + const detail: PromptInputProps.ChangeDetail = { + value: event.target.value, + }; + if (isTokenMode) { + detail.tokens = [...(tokens ?? [])]; + } + fireNonCancelableEvent(onChange, detail); + adjustInputHeight(); }; - const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; + // Keyboard handler for contentEditable + const handleEditableElementKeyDown = useStableCallback((event: React.KeyboardEvent) => { + // Handle arrow key navigation to skip ZWNJ in cursor spots + if (handleArrowKeyNavigation(event, skipNormalizationRef)) { + return; + } + + if (event.key === 'Enter' && event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); - 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'; + // Block action if cursor is inside a trigger token + if (editableElementRef.current && isCursorInTriggerToken(editableElementRef.current)) { + return; + } - const minTextareaHeight = `calc(${LINE_HEIGHT} + ${tokens.spaceScaledXxs} * 2)`; // the min height of Textarea with 1 row + if (editableElementRef.current) { + splitParagraphAtCursor(editableElementRef.current, editableState); + } + return; + } - if (maxRows === -1) { - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `max(${scrollHeight}, ${minTextareaHeight})`; - } 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})`; + if (event.key === 'Backspace' || event.key === 'Delete') { + if ( + editableElementRef.current && + handleReferenceTokenDeletion( + event, + event.key === 'Backspace', + editableElementRef.current, + editableState, + (message: string) => { + setTokenOperationAnnouncement(message); + setTimeout(() => setTokenOperationAnnouncement(''), 100); + }, + i18nStrings + ) + ) { + return; } } - }, [maxRows, LINE_HEIGHT, PADDING]); + if (event.key === 'Backspace' && tokens && editableElementRef.current) { + // Prevent backspace in completely empty input + if (tokens.length === 0) { + event.preventDefault(); + return; + } + + if ( + handleBackspaceAtParagraphStart( + event, + editableElementRef.current, + tokens, + tokensToText, + (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { + markTokensAsSent(detail.tokens); + fireNonCancelableEvent(onChange, detail); + }, + editableState + ) + ) { + return; + } + } + + if (event.key === 'Delete' && tokens && editableElementRef.current) { + if ( + handleDeleteAtParagraphEnd( + event, + editableElementRef.current, + tokens, + tokensToText, + lastKnownCursorPositionRef.current, + (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { + markTokensAsSent(detail.tokens); + fireNonCancelableEvent(onChange, detail); + }, + editableState + ) + ) { + return; + } + } + + fireKeyboardEvent(onKeyDown, event); + + // Handle space after closed trigger - move space out of trigger element + if ( + event.key === ' ' && + editableElementRef.current && + shortcuts && + handleSpaceAfterClosedTrigger( + event, + editableElementRef.current, + shortcuts.menuIsOpen, + shortcuts.triggerValueWhenClosed, + editableState, + ignoreCursorDetection, + menus + ) + ) { + return; + } + + if (keyboardHandlers) { + if (keyboardHandlers.handleMenuNavigation(event)) { + return; + } + } + + if (keyboardHandlers) { + keyboardHandlers.handleEnterKey(event); + } + }); + + const handleEditableElementBlur = useStableCallback(() => { + if (onBlur) { + fireNonCancelableEvent(onBlur); + } + }); + + // Auto-focus on mount (token mode only) useEffect(() => { - const handleResize = () => { - adjustTextareaHeight(); - }; + if (isTokenMode && autoFocus && editableElementRef.current) { + editableElementRef.current.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Lifecycle effects: window resize and cleanup + useEffect(() => { + // Window resize handler + const handleResize = () => adjustInputHeight(); window.addEventListener('resize', handleResize); + // Capture containers ref for cleanup + const containers = reactContainersRef.current; + + // Cleanup on unmount return () => { window.removeEventListener('resize', handleResize); + containers.clear(); }; - }, [adjustTextareaHeight]); + }, [adjustInputHeight]); + + // Handle menu option selection - replace TriggerToken with selected option + const handleMenuSelect = useStableCallback((option: MenuItem) => { + if (!activeMenu || !activeTriggerToken || !tokens) { + return; + } + + ignoreCursorDetection.current = true; + shortcuts.setCursorInTrigger(false); + setUpdateSource('menu-selection'); + + const result = handleMenuSelection( + tokens, + { + value: option.option.value || '', + label: option.option.label || option.option.value || '', + }, + activeMenu.id, + activeMenu.useAtStart ?? false, + activeTriggerToken + ); + + const value = tokensToText ? tokensToText(result.tokens) : getPromptText(result.tokens); + markTokensAsSent(result.tokens); + + editableState.menuSelectionTokenId = result.insertedToken.id || null; + editableState.menuSelectionIsPinned = activeMenu.useAtStart ?? false; + + const isPinned = activeMenu.useAtStart ?? false; + const tokenLabel = result.insertedToken.label || result.insertedToken.value; + const announcement = isPinned + ? (i18nStrings?.tokenPinnedAriaLabel?.(result.insertedToken) ?? `${tokenLabel} pinned`) + : (i18nStrings?.tokenInsertedAriaLabel?.(result.insertedToken) ?? `${tokenLabel} inserted`); + + setTokenOperationAnnouncement(announcement); + setTimeout(() => setTokenOperationAnnouncement(''), 100); + + fireNonCancelableEvent(onChange, { value, tokens: result.tokens }); + + fireNonCancelableEvent(onMenuItemSelect, { + menuId: activeMenu.id, + option: option.option, + }); + }); + + // Menu items controller - always call hooks + const menuItemsResult = useMenuItems({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + filterText: menuFilterText, + onSelectItem: handleMenuSelect, + }); + + // Menu items state and handlers + const [menuItemsState, menuItemsHandlers] = menuItemsResult; + + // Consolidated menu state ref for keyboard handlers + const menuStateRef = useRef({ + itemsState: menuItemsState, + itemsHandlers: menuItemsHandlers, + isOpen: menuIsOpen, + }); + + // Update ref when state changes + menuStateRef.current = { + itemsState: menuItemsState, + itemsHandlers: menuItemsHandlers, + isOpen: menuIsOpen, + }; + + // Create keyboard handlers + const keyboardHandlers = useMemo(() => { + if (!editableElementRef.current) { + return null; + } + + return createKeyboardHandlers({ + getMenuOpen: () => menuStateRef.current.isOpen, + getMenuItemsState: () => menuStateRef.current.itemsState, + getMenuItemsHandlers: () => menuStateRef.current.itemsHandlers, + getMenuStatusType: () => activeMenu?.statusType, + onAction: onAction ? detail => fireNonCancelableEvent(onAction, detail) : undefined, + tokensToText, + tokens, + closeMenu: () => { + ignoreCursorDetection.current = true; + shortcuts.setCursorInTrigger(false); + + setTimeout(() => { + ignoreCursorDetection.current = false; + }, CURSOR_DETECTION_DELAY); + }, + announceTokenOperation: (message: string) => { + setTokenOperationAnnouncement(message); + setTimeout(() => setTokenOperationAnnouncement(''), 100); + }, + i18nStrings, + disabled, + readOnly, + editableState, + editableElementRef, + lastKnownCursorPositionRef, + }); + }, [ + onAction, + tokensToText, + tokens, + ignoreCursorDetection, + shortcuts, + i18nStrings, + disabled, + readOnly, + activeMenu, + editableState, + lastKnownCursorPositionRef, + ]); + + // Menu load more controller + const menuLoadMoreResult = useMenuLoadMore({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + statusType: activeMenu?.statusType ?? 'finished', + onLoadItems: detail => { + fireNonCancelableEvent(onMenuLoadItems, detail); + }, + onLoadMoreItems: () => { + fireNonCancelableEvent(onMenuLoadItems, { + menuId: activeMenu?.id ?? '', + filteringText: undefined, // Pagination - no filter text + firstPage: false, + samePage: false, + }); + }, + }); + const menuLoadMoreHandlers = activeMenu ? menuLoadMoreResult : null; + + // Menu state management effect + useEffect(() => { + if (menuIsOpen && activeMenu && menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnMenuOpen(); + } + }, [menuIsOpen, activeMenu, menuLoadMoreHandlers]); + + // Highlight first item when menu opens or items change + const prevMenuOpenRef = useRef(false); + const prevItemsLengthRef = useRef(0); + + useEffect(() => { + const justOpened = menuIsOpen && !prevMenuOpenRef.current; + const itemsChanged = menuItemsState && menuItemsState.items.length !== prevItemsLengthRef.current; + + if ( + (justOpened || (menuIsOpen && itemsChanged)) && + menuItemsHandlers && + menuItemsState && + menuItemsState.items.length > 0 + ) { + setTimeout(() => { + menuItemsHandlers?.goHomeWithKeyboard(); + }, NEXT_TICK_TIMEOUT); + } + + prevMenuOpenRef.current = menuIsOpen; + prevItemsLengthRef.current = menuItemsState?.items.length ?? 0; + }, [menuIsOpen, menuItemsHandlers, menuItemsState, menuItemsState.items.length]); + + // Fire filter event when trigger token filter text changes useEffect(() => { - adjustTextareaHeight(); - }, [value, adjustTextareaHeight, maxRows, isCompactMode]); + if (activeTriggerToken && activeMenu && onMenuFilter) { + fireNonCancelableEvent(onMenuFilter, { + menuId: activeMenu.id, + filteringText: activeTriggerToken.value, + }); + } + }, [activeTriggerToken, activeMenu, onMenuFilter]); + + const hasActionButton = !!( + actionButtonIconName || + actionButtonIconSvg || + actionButtonIconUrl || + customPrimaryAction + ); - const attributes: React.TextareaHTMLAttributes = { + // Show placeholder in token mode when input is empty + const showPlaceholder = isTokenMode && placeholder && (!tokens || tokens.length === 0); + + const textareaAttributes: React.TextareaHTMLAttributes = { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, @@ -159,37 +751,100 @@ 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['textarea-readonly']]: readOnly, + [styles['placeholder-visible']]: showPlaceholder, + }), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, + spellCheck: spellcheck, + tabIndex: disabled ? -1 : 0, + 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 = menuItemsState?.highlightedOption ? highlightedMenuOptionIdSource : undefined; + + // Accessibility: Track token operations for screen reader announcements + const [tokenOperationAnnouncement, setTokenOperationAnnouncement] = useState(''); - const action = ( + // Always call useDropdownStatus hook + const menuDropdownStatusResult = useDropdownStatus({ + ...(activeMenu ?? {}), + isEmpty: !menuItemsState || menuItemsState.items.length === 0, + recoveryText: i18nStrings?.menuRecoveryText, + errorIconAriaLabel: i18nStrings?.menuErrorIconAriaLabel, + loadingText: i18nStrings?.menuLoadingText, + finishedText: i18nStrings?.menuFinishedText, + errorText: i18nStrings?.menuErrorText, + onRecoveryClick: () => { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnRecoveryClick(); + } + editableElementRef.current?.focus(); + }, + hasRecoveryCallback: Boolean(onMenuLoadItems), + }); + + const menuDropdownStatus = activeMenu ? menuDropdownStatusResult : null; + + const shouldRenderMenuDropdown = useMemo( + () => !!(menuIsOpen && activeMenu && menuItemsState), + [menuIsOpen, activeMenu, menuItemsState] + ); + + const actionButton = (
{customPrimaryAction ?? ( fireNonCancelableEvent(onAction, { value })} + onClick={() => { + fireNonCancelableEvent(onAction, { + value: getPlainTextValue(), + ...(isTokenMode && { tokens: [...(tokens ?? [])] }), + }); + }} variant="icon" /> )} @@ -210,6 +865,10 @@ const InternalPromptInput = React.forwardRef( role="region" style={getPromptInputStyles(style)} > + + {secondaryContent && (
)} +
- - {hasActionButton && !secondaryActions && action} + {isTokenMode ? ( + { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnScroll(); + } + }} + editableElementAttributes={editableElementAttributes} + i18nStrings={i18nStrings} + /> + ) : ( + + )} + {hasActionButton && !secondaryActions && actionButton}
+ {secondaryActions && (
{secondaryActions}
-
textareaRef.current?.focus()} /> - {hasActionButton && action} +
getActiveElement()?.focus()} /> + {hasActionButton && actionButton}
)}
diff --git a/src/prompt-input/shortcuts/use-shortcuts.ts b/src/prompt-input/shortcuts/use-shortcuts.ts new file mode 100644 index 0000000000..4705e3820e --- /dev/null +++ b/src/prompt-input/shortcuts/use-shortcuts.ts @@ -0,0 +1,521 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; +import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; + +import { getFirstScrollableParent } from '../../internal/utils/scrollable-containers'; +import { ELEMENT_TYPES } from '../core/constants'; +import { getCurrentSelection, getFirstRange } from '../core/cursor-utils'; +import { findElement } from '../core/dom-utils'; +import { processTokens, type UpdateSource } from '../core/token-operations'; +import { getPromptText } from '../core/token-operations'; +import { isHTMLElement, isTextNode, isTriggerToken } from '../core/type-guards'; +import type { PromptInputProps } from '../interfaces'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface UseShortcutsConfig { + isTokenMode: boolean; + tokens?: readonly PromptInputProps.InputToken[]; + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + editableElementRef: React.RefObject; +} + +export interface UseShortcutsResult { + // State + cursorInTrigger: boolean; + setCursorInTrigger: (inTrigger: boolean) => void; + ignoreCursorDetection: React.MutableRefObject; + triggerValueWhenClosed: string; + + // Menu state + activeTriggerToken: PromptInputProps.TriggerToken | null; + activeMenu: PromptInputProps.MenuDefinition | null; + menuIsOpen: boolean; + menuFilterText: string; + + // Trigger wrapper for dropdown positioning + triggerWrapperRef: React.MutableRefObject; + triggerWrapperReady: boolean; + + // Processing + processUserInput: (tokens: PromptInputProps.InputToken[]) => void; + processWithCursor: ( + tokens: PromptInputProps.InputToken[], + options?: { + source?: UpdateSource; + cursorAdjustment?: (savedPos: number, oldTokens: readonly PromptInputProps.InputToken[]) => number; + } + ) => void; + + // Update tracking + markTokensAsSent: (tokens: readonly PromptInputProps.InputToken[]) => void; + setUpdateSource: (source: UpdateSource) => void; +} + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +interface ShortcutsState { + cursorInTrigger: boolean; + setCursorInTrigger: (inTrigger: boolean) => void; + ignoreCursorDetection: React.MutableRefObject; + triggerValueWhenClosed: React.MutableRefObject; + lastSentTokens: React.MutableRefObject; + updateSource: React.MutableRefObject; + isExternalUpdate: (tokens: readonly PromptInputProps.InputToken[] | undefined) => boolean; + markTokensAsSent: (tokens: readonly PromptInputProps.InputToken[]) => void; + setUpdateSource: (source: UpdateSource) => void; +} + +function useShortcutsState(): ShortcutsState { + const [cursorInTrigger, setCursorInTrigger] = useState(false); + const ignoreCursorDetection = useRef(false); + const triggerValueWhenClosed = useRef(''); + const lastSentTokens = useRef(undefined); + const updateSource = useRef('external'); + + const isExternalUpdate = useStableCallback((tokens: readonly PromptInputProps.InputToken[] | undefined): boolean => { + return lastSentTokens.current !== tokens; + }); + + const markTokensAsSent = useStableCallback((tokens: readonly PromptInputProps.InputToken[]) => { + lastSentTokens.current = tokens; + }); + + const setUpdateSource = useStableCallback((source: UpdateSource) => { + updateSource.current = source; + }); + + return { + cursorInTrigger, + setCursorInTrigger, + ignoreCursorDetection, + triggerValueWhenClosed, + lastSentTokens, + updateSource, + isExternalUpdate, + markTokensAsSent, + setUpdateSource, + }; +} + +// ============================================================================ +// TOKEN PROCESSING +// ============================================================================ + +interface ProcessorConfig { + tokens?: readonly PromptInputProps.InputToken[]; + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + editableElementRef: React.RefObject; + state: ShortcutsState; +} + +function useTokenProcessor(config: ProcessorConfig) { + const { tokens, menus, tokensToText, onChange, state } = config; + const previousTokensRef = useRef(tokens); + + const emitTokenChange = useStableCallback((newTokens: PromptInputProps.InputToken[]) => { + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + state.markTokensAsSent(newTokens); + onChange({ value, tokens: newTokens }); + }); + + const processUserInput = useStableCallback((inputTokens: PromptInputProps.InputToken[]) => { + const processed = processTokens( + inputTokens, + { menus, tokensToText }, + { + source: 'user-input', + detectTriggers: true, + } + ); + + // Don't preserve cursor during trigger detection - cursor is already correct in DOM + emitTokenChange(processed); + }); + + const processWithCursor = useStableCallback( + ( + newTokens: PromptInputProps.InputToken[], + options: { + source?: UpdateSource; + cursorAdjustment?: (savedPos: number, oldTokens: readonly PromptInputProps.InputToken[]) => number; + } = {} + ) => { + const source = options.source ?? 'internal'; + state.setUpdateSource(source); + + // Just emit the token change - cursor stays where it is in DOM + emitTokenChange(newTokens); + } + ); + + // Effect: Process external token updates + useEffect(() => { + if (previousTokensRef.current === tokens) { + return; + } + + previousTokensRef.current = tokens; + + if (!state.isExternalUpdate(tokens)) { + return; + } + + state.setUpdateSource('external'); + + if (!tokens || !menus) { + return; + } + + const processed = processTokens( + tokens, + { menus, tokensToText }, + { + source: 'external', + detectTriggers: true, + } + ); + + const hasChanges = processed.length !== tokens.length || processed.some((t, i) => t !== tokens[i]); + + if (hasChanges) { + processWithCursor(processed, { source: 'external' }); + } + }, [tokens, menus, tokensToText, state, processWithCursor]); + + return { + processUserInput, + processWithCursor, + }; +} + +// ============================================================================ +// EFFECTS +// ============================================================================ + +interface EffectsConfig { + isTokenMode: boolean; + tokens?: readonly PromptInputProps.InputToken[]; + editableElementRef: React.RefObject; + state: ShortcutsState; + activeTriggerToken: PromptInputProps.TriggerToken | null; +} + +function isElementInView(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + // Find scrollable parent + const scrollableParent = getFirstScrollableParent(element); + + if (scrollableParent) { + // Check against scrollable parent + const parentRect = scrollableParent.getBoundingClientRect(); + + // Calculate visible portion + const visibleTop = Math.max(rect.top, parentRect.top); + const visibleBottom = Math.min(rect.bottom, parentRect.bottom); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const totalHeight = rect.height; + + // Consider visible if at least 50% is showing + return visibleHeight / totalHeight >= 0.5; + } + + // Check against viewport + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const visibleTop = Math.max(rect.top, 0); + const visibleBottom = Math.min(rect.bottom, viewportHeight); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const totalHeight = rect.height; + + // Consider visible if at least 50% is showing + return visibleHeight / totalHeight >= 0.5; +} + +function isCursorInTriggerElement(): boolean { + const selection = getCurrentSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = getFirstRange(); + if (!range?.collapsed) { + return false; + } + + let startElement: HTMLElement | null = null; + if (isHTMLElement(range.startContainer)) { + startElement = range.startContainer; + } else if (range.startContainer.parentElement) { + startElement = range.startContainer.parentElement; + } + + if (!startElement) { + return false; + } + + const triggerElement = findUpUntil(startElement, node => node.getAttribute('data-type') === ELEMENT_TYPES.TRIGGER); + + if (!triggerElement) { + return false; + } + + if (isTextNode(range.startContainer) && range.startContainer.parentElement === triggerElement) { + // Cursor must be after the trigger character (first character) + const result = range.startOffset > 0; + return result; + } + + return true; +} + +function useShortcutsEffects(config: EffectsConfig) { + const { activeTriggerToken, editableElementRef, state, tokens } = config; + + // Effect: Track trigger value when menu closes + useEffect(() => { + if (!state.cursorInTrigger && activeTriggerToken) { + state.triggerValueWhenClosed.current = activeTriggerToken.value; + } else if (state.cursorInTrigger) { + state.triggerValueWhenClosed.current = ''; + } + }, [state.cursorInTrigger, activeTriggerToken, state.triggerValueWhenClosed]); + + // Effect: Menu state management (cursor position + visibility) + useEffect(() => { + const hasTriggers = tokens?.some(isTriggerToken); + + if (!hasTriggers || !editableElementRef.current) { + state.setCursorInTrigger(false); + return; + } + + // Unified check for menu state: cursor in trigger AND trigger visible + const checkMenuState = () => { + if (!editableElementRef.current || state.ignoreCursorDetection.current) { + return; + } + + const isInTrigger = isCursorInTriggerElement(); + + // When cursor is in a trigger, check if THAT trigger is visible (not necessarily activeTriggerToken) + let triggerIsVisible = false; + if (isInTrigger) { + const selection = getCurrentSelection(); + if (selection?.rangeCount) { + const range = getFirstRange(); + if (range) { + let startElement: HTMLElement | null = null; + if (isHTMLElement(range.startContainer)) { + startElement = range.startContainer; + } else if (range.startContainer.parentElement) { + startElement = range.startContainer.parentElement; + } + + if (startElement) { + const triggerElement = findUpUntil( + startElement, + node => node.getAttribute('data-type') === ELEMENT_TYPES.TRIGGER + ); + if (triggerElement) { + triggerIsVisible = isElementInView(triggerElement); + } + } + } + } + } + + // Menu should be open if cursor is in trigger AND that trigger is visible + const shouldBeOpen = isInTrigger && triggerIsVisible; + + if (shouldBeOpen !== state.cursorInTrigger) { + state.setCursorInTrigger(shouldBeOpen); + } + }; + + // Initial check + checkMenuState(); + + // Listen to cursor changes + document.addEventListener('selectionchange', checkMenuState); + + // Listen to scroll changes + const scrollableParent = getFirstScrollableParent(editableElementRef.current); + if (scrollableParent) { + scrollableParent.addEventListener('scroll', checkMenuState); + } + + return () => { + document.removeEventListener('selectionchange', checkMenuState); + if (scrollableParent) { + scrollableParent.removeEventListener('scroll', checkMenuState); + } + }; + }, [tokens, state, editableElementRef, activeTriggerToken]); +} + +// MAIN HOOK + +export function useShortcuts(config: UseShortcutsConfig): UseShortcutsResult { + const { isTokenMode, tokens, menus, tokensToText, onChange, editableElementRef } = config; + + // Initialize state + const state = useShortcutsState(); + + // Derive active trigger token - find the one where cursor is located + // This needs to update whenever cursor moves, not just when cursorInTrigger changes + const [cursorUpdateTrigger, setCursorUpdateTrigger] = useState(0); + + const activeTriggerToken = useMemo((): PromptInputProps.TriggerToken | null => { + if (!tokens) { + return null; + } + + // Always return first trigger for cursor detection effect to work + const firstTrigger = tokens.find(isTriggerToken) ?? null; + + if (!firstTrigger) { + return null; + } + + // If cursor is in trigger and we have DOM access, find the specific trigger at cursor + if (state.cursorInTrigger && editableElementRef.current) { + const selection = getCurrentSelection(); + if (selection?.rangeCount) { + const range = getFirstRange(); + if (range?.collapsed) { + let startElement: HTMLElement | null = null; + if (isHTMLElement(range.startContainer)) { + startElement = range.startContainer; + } else if (range.startContainer.parentElement) { + startElement = range.startContainer.parentElement; + } + + if (startElement) { + const triggerElement = findUpUntil( + startElement, + node => node.getAttribute('data-type') === ELEMENT_TYPES.TRIGGER + ); + + if (triggerElement) { + const instanceId = triggerElement.getAttribute('data-id'); + if (instanceId) { + // Find trigger with matching instanceId + const matchingTrigger = tokens.find(t => isTriggerToken(t) && t.id === instanceId) as + | PromptInputProps.TriggerToken + | undefined; + if (matchingTrigger) { + return matchingTrigger; + } + } + } + } + } + } + } + + // Fallback to first trigger + return firstTrigger; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokens, state.cursorInTrigger, editableElementRef, cursorUpdateTrigger]); + + // Listen to cursor changes to update activeTriggerToken + useEffect(() => { + const handleSelectionChange = () => { + if (state.cursorInTrigger) { + setCursorUpdateTrigger(prev => prev + 1); + } + }; + + document.addEventListener('selectionchange', handleSelectionChange); + return () => document.removeEventListener('selectionchange', handleSelectionChange); + }, [state.cursorInTrigger]); + + // Also trigger update when cursorInTrigger changes to true + useEffect(() => { + if (state.cursorInTrigger) { + setCursorUpdateTrigger(prev => prev + 1); + } + }, [state.cursorInTrigger]); + + const activeMenu = useMemo( + () => + activeTriggerToken && state.cursorInTrigger + ? (menus?.find(m => m.trigger === activeTriggerToken.triggerChar) ?? null) + : null, + [activeTriggerToken, state.cursorInTrigger, menus] + ); + + const menuIsOpen = !!activeMenu; + const menuFilterText = activeTriggerToken?.value ?? ''; + + // Initialize processor + const processor = useTokenProcessor({ + tokens, + menus, + tokensToText, + onChange, + editableElementRef, + state, + }); + + // Setup effects + useShortcutsEffects({ + isTokenMode, + tokens, + editableElementRef, + state, + activeTriggerToken, + }); + + // Manage trigger wrapper ref for dropdown positioning + const triggerWrapperRef = useRef(null); + const [triggerWrapperReady, setTriggerWrapperReady] = useState(false); + + useEffect(() => { + // Only update ref when menu is actually open (cursor is in a trigger) + if (activeTriggerToken && menuIsOpen && editableElementRef.current) { + const triggerElement = findElement(editableElementRef.current, { + tokenType: ELEMENT_TYPES.TRIGGER, + tokenId: activeTriggerToken.id, + }); + + if (triggerElement) { + triggerWrapperRef.current = triggerElement; + setTriggerWrapperReady(true); + } + } else if (!menuIsOpen) { + triggerWrapperRef.current = null; + setTriggerWrapperReady(false); + } + }, [activeTriggerToken, menuIsOpen, editableElementRef]); + + return { + cursorInTrigger: state.cursorInTrigger, + setCursorInTrigger: state.setCursorInTrigger, + ignoreCursorDetection: state.ignoreCursorDetection, + triggerValueWhenClosed: state.triggerValueWhenClosed.current, + activeTriggerToken, + activeMenu, + menuIsOpen, + menuFilterText, + triggerWrapperRef, + triggerWrapperReady, + processUserInput: processor.processUserInput, + processWithCursor: processor.processWithCursor, + markTokensAsSent: state.markTokensAsSent, + setUpdateSource: state.setUpdateSource, + }; +} diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index d993ee6758..c2fb105756 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -122,32 +122,50 @@ $invalid-border-offset: constants.$invalid-control-left-padding; ); background-color: var(#{custom-props.$promptInputStyleBackgroundFocus}, awsui.$color-background-input-default); } + + // Prevent focus styles when disabled + &.disabled:focus-within, + &.disabled:focus { + @include styles.form-disabled-element( + $background-color: var( + #{custom-props.$promptInputStyleBackgroundDisabled}, + awsui.$color-background-input-disabled + ), + $border-color: var(#{custom-props.$promptInputStyleBorderColorDisabled}, awsui.$color-border-input-disabled), + $color: var(#{custom-props.$promptInputStyleColorDisabled}, awsui.$color-text-input-disabled), + $cursor: default + ); + box-shadow: var(#{custom-props.$promptInputStyleBoxShadowDisabled}); + } } .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 +173,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,9 +204,19 @@ $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; cursor: default; + overflow-y: hidden; + + // Prevent focus styles when disabled + &:focus-within, + &:focus { + @include styles.form-disabled-element; + box-shadow: none; + } &::placeholder { @include styles.form-placeholder-disabled; @@ -198,12 +230,25 @@ $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; + pointer-events: none; // Prevent cursor from getting stuck on placeholder in Safari + } &-wrapper { display: flex; + position: relative; } } +.editable-wrapper { + flex: 1; + min-inline-size: 0; + position: relative; +} + .primary-action { align-self: flex-end; flex-shrink: 0; @@ -271,3 +316,38 @@ $invalid-border-offset: constants.$invalid-control-left-padding; align-self: stretch; cursor: text; } + +.token-container { + display: inline-block; + user-select: all; + -webkit-user-select: all; + -moz-user-select: all; + padding-inline: awsui.$space-xxxs; +} + +.trigger-token { + text-decoration: underline dashed currentColor; + text-decoration-thickness: awsui.$border-divider-list-width; + text-underline-offset: awsui.$space-xxxs; +} + +// Paragraph elements - reset browser default margins/padding +.paragraph { + @include styles.styles-reset; + // Ensure paragraphs are only as tall as their line-height + margin: 0; + padding: 0; + // Preserve whitespace including trailing spaces + white-space: pre-wrap; + // Inherit color from parent (textarea) for disabled/readonly states + color: inherit; + + // Prevent focus styles on paragraphs when parent textarea is disabled + .textarea-disabled & { + &:focus-within, + &:focus { + background: inherit; + outline: none; + } + } +} 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/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts new file mode 100644 index 0000000000..d8d4150f32 --- /dev/null +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -0,0 +1,567 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; + +import { ELEMENT_TYPES } from '../core/constants'; +import { getCursorPosition, getCursorPositionAtIndex, setCursorPosition } from '../core/cursor-manager'; +import { + applySafariCursorFix, + calculateEndPosition, + extractTextFromCursorSpots, + positionCursorAfterMovedText, +} from '../core/cursor-utils'; +import { + createParagraph, + ensureValidEmptyState, + findAllParagraphs, + findElements, + isEmptyState, +} from '../core/dom-utils'; +import { extractTokensFromDOM, getPromptText } from '../core/token-operations'; +import { renderTokensToDOM } from '../core/token-renderer'; +import { enforcePinnedTokenOrdering } from '../core/token-utils'; +import { needsImmediateRenderForStyling } from '../core/trigger-utils'; +import { + isBreakToken, + isBRElement, + isPinnedReferenceToken, + isReferenceToken, + isTextNode, + isTextToken, + isTriggerToken, +} from '../core/type-guards'; +import { PromptInputProps } from '../interfaces'; + +interface CursorPositionOverride { + cursorPosition: number; + paragraphId: string | null; +} + +export interface EditableState { + skipNextZwnjUpdate: boolean; + skipNormalization: boolean; + skipCursorRestore: boolean; + targetParagraphId: string | null; + cursorPositionOverride: CursorPositionOverride | null; + menuSelectionTokenId: string | null; + menuSelectionIsPinned: boolean; + isDeleteOperation: boolean; +} + +export function createEditableState(): EditableState { + return { + skipNextZwnjUpdate: false, + skipNormalization: false, + skipCursorRestore: false, + targetParagraphId: null, + cursorPositionOverride: null, + menuSelectionTokenId: null, + menuSelectionIsPinned: false, + isDeleteOperation: false, + }; +} + +function shouldRerender( + oldTokens: readonly PromptInputProps.InputToken[] | undefined, + newTokens: readonly PromptInputProps.InputToken[] | undefined +): boolean { + if (!oldTokens || !newTokens) { + return true; + } + + if (oldTokens.length !== newTokens.length) { + return true; + } + + for (let i = 0; i < oldTokens.length; i++) { + const oldToken = oldTokens[i]; + const newToken = newTokens[i]; + + if (oldToken.type !== newToken.type) { + return true; + } + + if (isReferenceToken(oldToken) && isReferenceToken(newToken)) { + if (oldToken.id !== newToken.id) { + return true; + } + } + } + + return false; +} + +interface UseEditableOptions { + elementRef: React.RefObject; + reactContainersRef: React.MutableRefObject>; + tokens?: readonly PromptInputProps.InputToken[]; + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + adjustInputHeight: () => void; + disabled?: boolean; + readOnly?: boolean; + editableState: EditableState; + ignoreCursorDetection: React.MutableRefObject; + lastKnownCursorPositionRef: React.MutableRefObject; +} + +interface UseEditableReturn { + handleInput: () => void; + editableState: EditableState; +} + +export function useEditableTokens({ + elementRef, + reactContainersRef, + tokens, + menus, + tokensToText, + onChange, + adjustInputHeight, + disabled = false, + readOnly = false, + editableState, + ignoreCursorDetection, + lastKnownCursorPositionRef, +}: UseEditableOptions): UseEditableReturn { + const lastRenderedTokensRef = useRef(undefined); + const lastEmittedTokensRef = useRef(undefined); + const lastDisabledRef = useRef(disabled); + const lastReadOnlyRef = useRef(readOnly); + const skipNextZwnjUpdateRef = useRef(false); + const skipCursorRestoreRef = useRef(false); + const lastInputTimeRef = useRef(0); + const isTypingIntoEmptyLineRef = useRef(false); + + const handleInput = useCallback(() => { + lastInputTimeRef.current = Date.now(); + + if (!elementRef.current) { + return; + } + + // Remove trailing BRs FIRST, before capturing cursor + const allParagraphs = findAllParagraphs(elementRef.current); + allParagraphs.forEach(p => { + if (p.childNodes.length > 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK)) { + p.firstChild.remove(); + } + }); + + // Capture cursor position AFTER BR removal + const cursorPos = getCursorPosition(elementRef.current); + lastKnownCursorPositionRef.current = cursorPos; + + // Read flags from shared state + if (editableState.skipNextZwnjUpdate) { + skipNextZwnjUpdateRef.current = true; + editableState.skipNextZwnjUpdate = false; + } + + if (editableState.skipCursorRestore) { + skipCursorRestoreRef.current = true; + editableState.skipCursorRestore = false; + } + + if (elementRef.current.children.length === 0) { + ensureValidEmptyState(elementRef.current); + } + + const paragraphs = findAllParagraphs(elementRef.current); + + // Extract text from cursor spots and track moved text node + const { movedTextNode } = extractTextFromCursorSpots(paragraphs, true); + + // If cursor was in a spot, position it at the end of the moved text + if (movedTextNode) { + positionCursorAfterMovedText(movedTextNode, elementRef.current, lastKnownCursorPositionRef); + } + + const directTextNodes = Array.from(elementRef.current.childNodes).filter( + node => isTextNode(node) && node.textContent?.trim() + ); + + if (directTextNodes.length > 0) { + // Find or create a paragraph to move the text into + let targetP = findAllParagraphs(elementRef.current)[0]; + if (!targetP) { + targetP = createParagraph(); + elementRef.current.appendChild(targetP); + } + + // Move text nodes into the paragraph + directTextNodes.forEach(textNode => { + targetP!.appendChild(textNode); + }); + } + + // Extract tokens + let extractedTokens = extractTokensFromDOM(elementRef.current, menus); + + // If a new trigger was just created, render immediately to create the trigger element + // This minimizes the window where cursor is at wrong position + const newTriggers = extractedTokens.filter(isTriggerToken); + const oldTriggers = lastEmittedTokensRef.current?.filter(isTriggerToken) || []; + + // Check if we need immediate rendering + const isNewTrigger = newTriggers.length > oldTriggers.length; + const hasStylingChange = needsImmediateRenderForStyling( + newTriggers.filter(isTriggerToken), + oldTriggers.filter(isTriggerToken) + ); + + if (isNewTrigger || hasStylingChange) { + // Save cursor position before rendering + const savedCursorPos = getCursorPosition(elementRef.current); + + // Render immediately to update trigger element + renderTokensToDOM(extractedTokens, elementRef.current, reactContainersRef.current, { disabled, readOnly }); + + if (isNewTrigger) { + // Find the new trigger (not in oldTriggers) + const oldTriggerIds = new Set(oldTriggers.map(t => (isTriggerToken(t) ? t.id : undefined))); + const newTrigger = newTriggers.find(t => isTriggerToken(t) && !oldTriggerIds.has(t.id)); + + // Position cursor inside the new trigger element + if (newTrigger && isTriggerToken(newTrigger) && newTrigger.id) { + const triggerElements = findElements(elementRef.current, { + tokenType: ELEMENT_TYPES.TRIGGER, + tokenId: newTrigger.id, + }); + if (triggerElements.length > 0) { + const triggerElement = triggerElements[0]; + const triggerTextNode = triggerElement.firstChild; + if (triggerTextNode && isTextNode(triggerTextNode)) { + const range = document.createRange(); + range.setStart(triggerTextNode, triggerTextNode.textContent?.length || 0); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + } + } else { + // Styling change only - restore cursor to saved position + setCursorPosition(elementRef.current, savedCursorPos); + } + } + + // If all content was deleted, ensure proper empty state + // Note: break tokens are valid content (newlines), don't clear them + if (extractedTokens.length === 0) { + // Ensure we have exactly one paragraph with BR + if (!isEmptyState(elementRef.current)) { + ensureValidEmptyState(elementRef.current); + // Cursor will be restored by unified restoration to position 0 + lastKnownCursorPositionRef.current = 0; + } + extractedTokens = []; + } + + const movedTokens = enforcePinnedTokenOrdering(extractedTokens); + const tokensWereMoved = movedTokens.some((t, i) => t !== extractedTokens[i]); + + if (tokensWereMoved) { + extractedTokens = movedTokens; + + // When tokens are moved, position cursor after all content + const position = calculateEndPosition(movedTokens); + lastKnownCursorPositionRef.current = position; + + // Render immediately to avoid showing intermediate state + renderTokensToDOM(movedTokens, elementRef.current, reactContainersRef.current, { disabled, readOnly }); + + // Position cursor immediately to avoid flicker + // Only if element has focus to avoid stealing focus + requestAnimationFrame(() => { + if (elementRef.current && document.activeElement === elementRef.current) { + setCursorPosition(elementRef.current, position); + } + }); + } + + const value = tokensToText ? tokensToText(extractedTokens) : getPromptText(extractedTokens); + onChange({ value, tokens: extractedTokens }); + + lastEmittedTokensRef.current = extractedTokens; + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onChange, adjustInputHeight, tokensToText]); + + useLayoutEffect(() => { + if (!elementRef.current || disabled) { + return; + } + if (elementRef.current.children.length === 0) { + renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { disabled, readOnly }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!elementRef.current) { + return; + } + + // Check if disabled/readOnly changed - force rerender if so + const stateChanged = lastDisabledRef.current !== disabled || lastReadOnlyRef.current !== readOnly; + lastDisabledRef.current = disabled; + lastReadOnlyRef.current = readOnly; + + // Check if a trigger split+merge happened (same token count, but text token value changed) + // This is a structural change that needs cursor repositioning + const triggerSplitAndMerged = + lastRenderedTokensRef.current && + tokens && + lastRenderedTokensRef.current.length === tokens.length && + tokens.some((token, i) => { + const oldToken = lastRenderedTokensRef.current![i]; + const prevToken = i > 0 ? tokens[i - 1] : null; + // Detect: text token after trigger, value changed by exactly 1 space at start + return ( + isTextToken(token) && + isTextToken(oldToken) && + prevToken && + isTriggerToken(prevToken) && + token.value.length === oldToken.value.length + 1 && + token.value.startsWith(' ') && + token.value.substring(1) === oldToken.value + ); + }); + + const needsRerender = + stateChanged || shouldRerender(lastRenderedTokensRef.current, tokens) || triggerSplitAndMerged; + + if (!needsRerender) { + lastRenderedTokensRef.current = tokens; + return; + } + + if (lastRenderedTokensRef.current && tokens && lastRenderedTokensRef.current.length === 0 && tokens.length === 0) { + lastRenderedTokensRef.current = tokens; + return; + } + + if (skipNextZwnjUpdateRef.current) { + skipNextZwnjUpdateRef.current = false; + } + + if (editableState.skipCursorRestore) { + skipCursorRestoreRef.current = true; + editableState.skipCursorRestore = false; + } + + const shouldRestoreCursor = !skipCursorRestoreRef.current; + + skipCursorRestoreRef.current = false; + + let savedCursorPosition = 0; + let hasCursorOverride = false; + + if (shouldRestoreCursor) { + // Check if we have a cursor position override with a pre-calculated position + if (editableState.cursorPositionOverride) { + savedCursorPosition = editableState.cursorPositionOverride.cursorPosition; + hasCursorOverride = true; + editableState.cursorPositionOverride = null; + } else { + savedCursorPosition = lastKnownCursorPositionRef.current; + } + } + + // Special case: typing into empty line OR typing after a reference + // These cases need immediate cursor restoration to prevent jumping + const prevLastToken = lastRenderedTokensRef.current?.[lastRenderedTokensRef.current.length - 1]; + const justStartedNewLine = prevLastToken && isBreakToken(prevLastToken); + const wasCompletelyEmpty = !lastRenderedTokensRef.current || lastRenderedTokensRef.current.length === 0; + const justAfterReference = prevLastToken && isReferenceToken(prevLastToken); + + // Check if CURRENT LINE (after last break) is only text + let currentLineIsText = false; + if (tokens && tokens.length > 0) { + let lastBreakIndex = -1; + for (let i = tokens.length - 1; i >= 0; i--) { + if (isBreakToken(tokens[i])) { + lastBreakIndex = i; + break; + } + } + const currentLineTokens = tokens.slice(lastBreakIndex + 1); + currentLineIsText = currentLineTokens.length > 0 && currentLineTokens.every(isTextToken); + } + + // Start tracking when typing into empty line OR after reference + if ((justStartedNewLine || wasCompletelyEmpty || justAfterReference) && currentLineIsText) { + isTypingIntoEmptyLineRef.current = true; + } + + // Stop tracking when current line has non-text tokens + if (!currentLineIsText && tokens && tokens.length > 0) { + isTypingIntoEmptyLineRef.current = false; + } + + // Reset when empty + if (!tokens || tokens.length === 0) { + isTypingIntoEmptyLineRef.current = false; + } + + const isTypingIntoEmptyLine = isTypingIntoEmptyLineRef.current; + + lastRenderedTokensRef.current = tokens; + + if (isTypingIntoEmptyLine) { + const renderResult = renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { + disabled, + readOnly, + }); + + // If a new trigger was just created, position cursor inside it immediately + if (renderResult.newTriggerElement) { + const triggerTextNode = renderResult.newTriggerElement.firstChild; + if (triggerTextNode && isTextNode(triggerTextNode)) { + const range = document.createRange(); + range.setStart(triggerTextNode, triggerTextNode.textContent?.length || 0); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + adjustInputHeight(); + return; + } + } + + // Otherwise restore cursor immediately (synchronously) to prevent jumping + if (document.activeElement === elementRef.current && shouldRestoreCursor) { + setCursorPosition(elementRef.current, savedCursorPosition); + } + + adjustInputHeight(); + return; + } + + // Calculate cursor position for space-after-trigger case + let cursorPositionToRestore: number | null = null; + if (triggerSplitAndMerged && tokens) { + // Special case: space was added after trigger, position after the space + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const nextToken = tokens[i + 1]; + + if (isTriggerToken(token) && nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { + cursorPositionToRestore = calculateEndPosition(tokens.slice(0, i + 1)) + 1; + break; + } + } + } + + renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { disabled, readOnly }); + + // Check if we have only pinned references (after submit) + const onlyPinnedReferences = tokens && tokens.length > 0 && tokens.every(isPinnedReferenceToken); + + // Check if this is a special case that needs custom cursor positioning + const needsCalculatedCursorPosition = + editableState.menuSelectionTokenId || + hasCursorOverride || + cursorPositionToRestore !== null || + onlyPinnedReferences; + + // For normal structural changes, restore cursor immediately using lastKnownCursorPositionRef + // This allows insertText and handleInput to control the final cursor position + // For special cases, use RAF restoration with calculated position + if (!needsCalculatedCursorPosition && document.activeElement === elementRef.current) { + setCursorPosition(elementRef.current, lastKnownCursorPositionRef.current); + adjustInputHeight(); + return; + } + + // ============================================================================ + // UNIFIED CURSOR RESTORATION (RAF-based, for special cases) + // ============================================================================ + // After renderTokensToDOM, always restore cursor position using lastKnownCursorPositionRef + // Special cases update the ref before restoration, not position directly + + requestAnimationFrame(() => + requestAnimationFrame(() => { + if (!elementRef.current) { + return; + } + + // Calculate target position based on special cases + let targetPosition = savedCursorPosition; + + // Special case 1: Menu selection - position after the selected reference + if (editableState.menuSelectionTokenId) { + const tokenId = editableState.menuSelectionTokenId; + const isPinned = editableState.menuSelectionIsPinned; + editableState.menuSelectionTokenId = null; + editableState.menuSelectionIsPinned = false; + + let targetWrapper: Element | null = null; + + if (isPinned) { + const pinnedElements = findElements(elementRef.current, { tokenType: ELEMENT_TYPES.PINNED }); + const lastPinned = pinnedElements[pinnedElements.length - 1]; + if (lastPinned) { + targetWrapper = lastPinned.closest(`[data-type="${ELEMENT_TYPES.PINNED}"]`); + } + } else { + const wrappers = findElements(elementRef.current, { + tokenType: ELEMENT_TYPES.REFERENCE, + tokenId, + }); + targetWrapper = wrappers[wrappers.length - 1]; + } + + if (targetWrapper && tokens) { + const refIndex = tokens.findIndex(t => isReferenceToken(t) && t.id === tokenId); + if (refIndex >= 0) { + // Calculate position after this reference + targetPosition = getCursorPositionAtIndex(tokens, refIndex); + } + } + + ignoreCursorDetection.current = false; + } + + // Special case 2: Space after trigger - position after the space + if (cursorPositionToRestore !== null) { + targetPosition = cursorPositionToRestore; + } + + // Special case 3: Only pinned references (after submit) + // Position cursor after all pinned references + if (onlyPinnedReferences && tokens) { + targetPosition = calculateEndPosition(tokens); + } + + // Unified restoration: only restore if element has focus + // This prevents stealing focus from other elements + if (document.activeElement === elementRef.current) { + setCursorPosition(elementRef.current, targetPosition); + + // Apply Safari ghost cursor fix if needed + applySafariCursorFix(elementRef.current, editableState, targetPosition); + } + }) + ); + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disabled, readOnly, tokens, adjustInputHeight]); + + return { + handleInput, + editableState, + }; +} + +export type SetCursorPositionCallback = (position: number | null) => void; diff --git a/src/prompt-input/utils/insert-text-content-editable.ts b/src/prompt-input/utils/insert-text-content-editable.ts new file mode 100644 index 0000000000..9ec507b113 --- /dev/null +++ b/src/prompt-input/utils/insert-text-content-editable.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { setCursorPosition } from '../core/cursor-manager'; + +export function insertTextIntoContentEditable( + element: HTMLElement, + text: string, + cursorStart: number | undefined, + cursorEnd: number | undefined +): void { + element.focus(); + + // Set cursor to insertion position + if (cursorStart !== undefined) { + setCursorPosition(element, cursorStart); + } + + // Get current selection + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + // Create text node with ONLY the text passed to insertText + const textNode = document.createTextNode(text); + + // Insert the node at the current cursor position + range.insertNode(textNode); + + // Trigger input event to let handleInput() process the changes + element.dispatchEvent(new Event('input', { bubbles: true })); + + // Set cursor position AFTER input event processing + requestAnimationFrame(() => { + if (cursorEnd !== undefined) { + setCursorPosition(element, cursorEnd); + } else { + const insertPosition = cursorStart ?? 0; + setCursorPosition(element, insertPosition + text.length); + } + }); +} diff --git a/src/test-utils/dom/prompt-input/index.ts b/src/test-utils/dom/prompt-input/index.ts index a241b1f79a..85c76ec966 100644 --- a/src/test-utils/dom/prompt-input/index.ts +++ b/src/test-utils/dom/prompt-input/index.ts @@ -1,18 +1,68 @@ // 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); + } +} + 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. */ @@ -36,25 +86,122 @@ export default class PromptInputWrapper extends ComponentWrapper { } /** + * Finds the menu dropdown (always in portal due to expandToViewport=true). + */ + findMenu(): PromptInputMenuWrapper | null { + return createWrapper().findComponent(`.${dropdownStyles.dropdown}[data-open=true]`, PromptInputMenuWrapper); + } + + /** + * 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); + }); + } + } + + /** + * Checks if the menu is currently open. + */ + @usesDom + isMenuOpen(): boolean { + const menu = this.findMenu(); + return menu !== null; + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param value value of option to select + */ + @usesDom + selectMenuOptionByValue(value: string): void { + act(() => { + const menu = this.findMenu(); + if (!menu) { + throw new Error('Menu not found'); + } + 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 + */ + @usesDom + selectMenuOption(optionIndex: number): void { act(() => { - const event = new Event('change', { bubbles: true, cancelable: false }); - setNativeValue(element, value); - element.dispatchEvent(event); + const menu = this.findMenu(); + if (!menu) { + throw new Error('Menu not found'); + } + 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 9c54d87111..3bd265f8d6 100644 --- a/src/token/internal.tsx +++ b/src/token/internal.tsx @@ -97,8 +97,10 @@ function InternalToken({ } }; + const SpanOrDivTag = isInline ? 'span' : 'div'; + return ( -
-
)} -
+ {!!tooltipContent && isInline && isEllipsisActive && showTooltip && ( )} -
+ ); }