From 2c7ad2fbe517c29ff3c560c1577303118e303e1b Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 6 Nov 2025 11:51:43 +0100 Subject: [PATCH] feat: Add PromptInput component --- package.json | 1 + pages/prompt-input/custom-icon.png | Bin 0 -> 381 bytes pages/prompt-input/permutations.page.tsx | 142 ++++ .../prompt-input/prompt-input-integ.page.tsx | 44 ++ pages/prompt-input/simple.page.tsx | 325 ++++++++ .../support-prompt-group/in-context.page.tsx | 2 +- pages/utils/permutations-view.tsx | 43 ++ pages/utils/permutations.ts | 34 + scripts/utils/pluralize.js | 1 + .../__snapshots__/documenter.test.ts.snap | 700 ++++++++++++++++++ .../test-utils-wrappers.test.tsx.snap | 66 ++ src/index.tsx | 3 + .../base-component/get-data-attributes.ts | 2 +- src/internal/context/form-field-context.ts | 77 ++ src/internal/events/index.ts | 29 + src/internal/shared.scss | 131 ++++ src/internal/types.ts | 4 + src/internal/utils/convert-auto-complete.ts | 12 + src/internal/utils/pointer-events-mock.ts | 10 + src/internal/utils/with-native-attributes.tsx | 80 ++ .../__tests__/prompt-input.test.tsx | 458 ++++++++++++ src/prompt-input/index.tsx | 61 ++ src/prompt-input/interfaces.ts | 203 +++++ src/prompt-input/internal.tsx | 264 +++++++ src/prompt-input/styles.scss | 194 +++++ src/prompt-input/test-classes/styles.scss | 27 + src/prompt-input/utils.ts | 12 + src/test-utils/dom/prompt-input/index.ts | 60 ++ src/test-utils/types/global.d.ts | 5 + types/global.d.ts | 5 + 30 files changed, 2993 insertions(+), 2 deletions(-) create mode 100644 pages/prompt-input/custom-icon.png create mode 100644 pages/prompt-input/permutations.page.tsx create mode 100644 pages/prompt-input/prompt-input-integ.page.tsx create mode 100644 pages/prompt-input/simple.page.tsx create mode 100644 pages/utils/permutations-view.tsx create mode 100644 pages/utils/permutations.ts create mode 100644 src/internal/context/form-field-context.ts create mode 100644 src/internal/types.ts create mode 100644 src/internal/utils/convert-auto-complete.ts create mode 100644 src/internal/utils/pointer-events-mock.ts create mode 100644 src/internal/utils/with-native-attributes.tsx create mode 100644 src/prompt-input/__tests__/prompt-input.test.tsx create mode 100644 src/prompt-input/index.tsx create mode 100644 src/prompt-input/interfaces.ts create mode 100644 src/prompt-input/internal.tsx create mode 100644 src/prompt-input/styles.scss create mode 100644 src/prompt-input/test-classes/styles.scss create mode 100644 src/prompt-input/utils.ts create mode 100644 src/test-utils/dom/prompt-input/index.ts diff --git a/package.json b/package.json index 1abfc00..c7930a4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "./avatar": "./avatar/index.js", "./chat-bubble": "./chat-bubble/index.js", "./loading-bar": "./loading-bar/index.js", + "./prompt-input": "./prompt-input/index.js", "./support-prompt-group": "./support-prompt-group/index.js", "./test-utils/dom": "./test-utils/dom/index.js", "./test-utils/selectors": "./test-utils/selectors/index.js", diff --git a/pages/prompt-input/custom-icon.png b/pages/prompt-input/custom-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f3dbedf0c18a03464d3ea4ed52959e1a05a0cffd GIT binary patch literal 381 zcmV-@0fPRCP)_jW||SROqsUXk}(!U0$rX zzm=n@#K_C5tgfT6xZ=a7?U_)($w$Rhm zdU<){+0ccBgp-w)wYRyPo}cZYUhCh~@yx8@(ZL!p->d)t0Io?yK~xx(Rmj;6f-n$3 z(W%-p&}k_xi?WD{g6sePp3}q_Jx@0m67eSiPpco~L1bx~s#)vv#E}VTGp0LhyFOM6 zZNju8>bh7wHqmrj`aVVrLb2Jp#oAZ`1R|D&lUNjzQ-D`IoR*byS48E(BhjSUS>K4z zL%kuQvZ_jXrOWPV;Vys* b@FS=([ + { + invalid: [false, true], + warning: [false, true], + actionButtonIconName: [undefined, "send"], + value: [ + "", + "Short value 1", + "Long value 1, enough to extend beyond the input width. Lorem ipsum dolor sit amet, consectetur adipisicing 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.", + ], + }, + { + value: [""], + placeholder: [ + "Short placeholder", + "Long placeholder, enough to extend beyond the input width. Lorem ipsum dolor sit amet, consectetur adipisicing 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.", + ], + }, + { + disabled: [false, true], + actionButtonIconName: [undefined, "send"], + value: ["", "Short value 2"], + }, + { + value: [ + "", + "Short value 3", + "Long value 3, enough to extend beyond the input width. Lorem ipsum dolor sit amet, consectetur adipisicing 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.", + ], + actionButtonIconSvg: [ + + + + + + + + , + ], + }, + { + value: [ + "", + "Short value 4", + "Long value 4, enough to extend beyond the input width. Lorem ipsum dolor sit amet, consectetur adipisicing 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.", + ], + actionButtonIconUrl: [img], + actionButtonIconAlt: ["Letter A"], + }, + { + value: ["Short value 5"], + actionButtonIconName: [undefined, "send"], + secondaryActions: [undefined, "secondary actions 1"], + secondaryContent: [undefined, "secondary content 1"], + invalid: [false, true], + }, + { + value: ["Short value 6"], + actionButtonIconName: ["send"], + secondaryActions: ["secondary actions 2"], + secondaryContent: ["secondary content 2"], + disableSecondaryActionsPaddings: [false, true], + disableSecondaryContentPaddings: [false, true], + }, + { + value: ["Short value for custom primary actions"], + actionButtonIconName: [undefined, "send"], + customPrimaryAction: [ + undefined, + + + + ); +} diff --git a/pages/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx new file mode 100644 index 0000000..46e972e --- /dev/null +++ b/pages/prompt-input/simple.page.tsx @@ -0,0 +1,325 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createRef, useContext, useEffect, useRef, useState } from "react"; + +import AppLayout from "@cloudscape-design/components/app-layout"; +import Box from "@cloudscape-design/components/box"; +import ButtonGroup, { ButtonGroupProps } from "@cloudscape-design/components/button-group"; +import Checkbox from "@cloudscape-design/components/checkbox"; +import ColumnLayout from "@cloudscape-design/components/column-layout"; +import FileTokenGroup from "@cloudscape-design/components/file-token-group"; +import { FileUploadProps } from "@cloudscape-design/components/file-upload"; +import FormField from "@cloudscape-design/components/form-field"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import SplitPanel from "@cloudscape-design/components/split-panel"; + +import { PromptInput } from "../../lib/components"; +import AppContext, { AppContextType } from "../app/app-context"; + +const MAX_CHARS = 2000; + +const labels = { + navigation: "Side navigation", + navigationToggle: "Open navigation", + navigationClose: "Close navigation", + notifications: "Notifications", + tools: "Tools", + toolsToggle: "Open tools", + toolsClose: "Close tools", +}; + +export const i18nStrings: FileUploadProps.I18nStrings = { + uploadButtonText: (multiple: boolean) => (multiple ? "Choose files" : "Choose file"), + dropzoneText: (multiple) => (multiple ? "Drop files to upload" : "Drop file to upload"), + removeFileAriaLabel: (fileIndex) => `Remove file ${fileIndex + 1}`, + limitShowFewer: "Show fewer files", + limitShowMore: "Show more files", + errorIconAriaLabel: "Error", + warningIconAriaLabel: "Warning", +}; + +type DemoContext = React.Context< + AppContextType<{ + isDisabled: boolean; + isReadOnly: boolean; + isInvalid: boolean; + hasWarning: boolean; + hasText: boolean; + hasSecondaryContent: boolean; + hasSecondaryActions: boolean; + hasPrimaryActions: boolean; + hasInfiniteMaxRows: 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."; + +export default function PromptInputPage() { + const [textareaValue, setTextareaValue] = useState(""); + const [valueInSplitPanel, setValueInSplitPanel] = useState(""); + const [files, setFiles] = useState([]); + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + + const { + isDisabled, + isReadOnly, + isInvalid, + hasWarning, + hasText, + hasSecondaryActions, + hasSecondaryContent, + hasPrimaryActions, + hasInfiniteMaxRows, + } = urlParams; + + const [items, setItems] = 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 }, + ]); + + useEffect(() => { + if (hasText) { + setTextareaValue(placeholderText); + } + }, [hasText]); + + useEffect(() => { + if (textareaValue !== placeholderText) { + setUrlParams({ hasText: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [textareaValue]); + + useEffect(() => { + if (items.length === 0) { + ref.current?.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + useEffect(() => { + const newItems = items.map((item) => ({ + label: item.label, + dismissLabel: item.dismissLabel, + disabled: isDisabled, + })); + setItems([...newItems]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled]); + + const ref = createRef(); + + const buttonGroupRef = 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 + + + + + + + + + + + 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: {textareaValue.length}/{MAX_CHARS} + + } + label={User prompt} + i18nStrings={{ errorIconAriaLabel: "Error" }} + > + setTextareaValue(event.detail.value)} + onAction={(event) => window.alert(`Submitted the following: ${event.detail.value}`)} + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || textareaValue.length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + + detail.id.includes("files") && setFiles(detail.files)} + items={[ + { + type: "icon-file-input", + id: "files", + text: "Upload files", + multiple: true, + }, + { + type: "icon-button", + id: "expand", + iconName: "expand", + text: "Go full page", + disabled: isDisabled || isReadOnly, + }, + { + type: "icon-button", + id: "remove", + iconName: "remove", + text: "Remove", + disabled: isDisabled || isReadOnly, + }, + ]} + variant="icon" + /> + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> + +
+ + +
+ } + splitPanel={ + + setValueInSplitPanel(event.detail.value)} + /> + + } + /> + ); +} diff --git a/pages/support-prompt-group/in-context.page.tsx b/pages/support-prompt-group/in-context.page.tsx index 34681c3..899278d 100644 --- a/pages/support-prompt-group/in-context.page.tsx +++ b/pages/support-prompt-group/in-context.page.tsx @@ -5,9 +5,9 @@ import { createRef, useState } from "react"; import Button from "@cloudscape-design/components/button"; import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; -import PromptInput from "@cloudscape-design/components/prompt-input"; import SpaceBetween from "@cloudscape-design/components/space-between"; +import { PromptInput } from "../../lib/components"; import { ChatBubble, SupportPromptGroup } from "../../lib/components"; import { Page } from "../app/templates"; import { TestBed } from "../app/test-bed"; diff --git a/pages/utils/permutations-view.tsx b/pages/utils/permutations-view.tsx new file mode 100644 index 0000000..4b38cf3 --- /dev/null +++ b/pages/utils/permutations-view.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import {} from "react"; + +import SpaceBetween from "@cloudscape-design/components/space-between"; + +interface PermutationsViewProps { + permutations: ReadonlyArray; + render: (props: T, index?: number) => React.ReactElement; +} + +function formatValue(key: string, value: any) { + if (typeof value === "function") { + return value.toString(); + } + if (value && value.$$typeof) { + // serialize React content to string + // TODO: Pretty-print original JSX, currently this are bare VDOM nodes + return JSON.stringify(value); + } + return value; +} + +const maximumPermutations = 276; + +export default function PermutationsView({ permutations, render }: PermutationsViewProps) { + if (permutations.length > maximumPermutations) { + throw new Error(`Too many permutations (${permutations.length}), maximum is ${maximumPermutations}`); + } + + return ( + + {permutations.map((permutation, index) => { + const id = JSON.stringify(permutation, formatValue); + return ( +
+ {render(permutation, index)} +
+ ); + })} +
+ ); +} diff --git a/pages/utils/permutations.ts b/pages/utils/permutations.ts new file mode 100644 index 0000000..56971bd --- /dev/null +++ b/pages/utils/permutations.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import flatten from "lodash/flatten"; + +export type ComponentPermutations = { + [prop in keyof Props]: ReadonlyArray; +}; + +export default function createPermutations(permutations: Array>) { + return flatten(permutations.map((set) => doCreatePermutations(set))); +} + +function doCreatePermutations(permutations: ComponentPermutations) { + const result: Props[] = []; + const propertyNames = Object.keys(permutations) as Array; + + function addPermutations(remainingPropertyNames: Array, currentPropertyValues: Partial) { + if (remainingPropertyNames.length === 0) { + result.push({ ...currentPropertyValues } as Props); + return; + } + + const propertyName = remainingPropertyNames[0]; + + permutations[propertyName].forEach((propertyValue) => { + currentPropertyValues[propertyName] = propertyValue; + addPermutations(remainingPropertyNames.slice(1), currentPropertyValues); + }); + } + + addPermutations(propertyNames, {}); + + return result; +} diff --git a/scripts/utils/pluralize.js b/scripts/utils/pluralize.js index ac9cde9..629cfd4 100644 --- a/scripts/utils/pluralize.js +++ b/scripts/utils/pluralize.js @@ -4,6 +4,7 @@ const pluralizationMap = { Avatar: "Avatars", ChatBubble: "ChatBubbles", LoadingBar: "LoadingBars", + PromptInput: "PromptInputs", SupportPromptGroup: "SupportPromptGroups", }; diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 14716bb..43e2dee 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -280,6 +280,706 @@ with rounded corners.", } `; +exports[`definition for prompt-input matches the snapshot > prompt-input 1`] = ` +{ + "dashCaseName": "prompt-input", + "events": [ + { + "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.", + "detailInlineType": { + "name": "BaseChangeDetail", + "properties": [ + { + "name": "value", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "BaseChangeDetail", + "name": "onAction", + }, + { + "cancelable": false, + "description": "Called when input focus is removed from the UI control.", + "name": "onBlur", + }, + { + "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.", + "detailInlineType": { + "name": "BaseChangeDetail", + "properties": [ + { + "name": "value", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "BaseChangeDetail", + "name": "onChange", + }, + { + "cancelable": false, + "description": "Called when input focus is moved to the UI control.", + "name": "onFocus", + }, + { + "cancelable": true, + "description": "Called when the underlying native textarea emits a \`keydown\` event. +The event \`detail\` contains the \`keyCode\` and information +about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", + "detailInlineType": { + "name": "BaseKeyDetail", + "properties": [ + { + "name": "altKey", + "optional": false, + "type": "boolean", + }, + { + "name": "ctrlKey", + "optional": false, + "type": "boolean", + }, + { + "name": "isComposing", + "optional": false, + "type": "boolean", + }, + { + "name": "key", + "optional": false, + "type": "string", + }, + { + "name": "keyCode", + "optional": false, + "type": "number", + }, + { + "name": "metaKey", + "optional": false, + "type": "boolean", + }, + { + "name": "shiftKey", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "BaseKeyDetail", + "name": "onKeyDown", + }, + { + "cancelable": true, + "description": "Called when the underlying native textarea emits a \`keyup\` event. +The event \`detail\` contains the \`keyCode\` and information +about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", + "detailInlineType": { + "name": "BaseKeyDetail", + "properties": [ + { + "name": "altKey", + "optional": false, + "type": "boolean", + }, + { + "name": "ctrlKey", + "optional": false, + "type": "boolean", + }, + { + "name": "isComposing", + "optional": false, + "type": "boolean", + }, + { + "name": "key", + "optional": false, + "type": "string", + }, + { + "name": "keyCode", + "optional": false, + "type": "number", + }, + { + "name": "metaKey", + "optional": false, + "type": "boolean", + }, + { + "name": "shiftKey", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "BaseKeyDetail", + "name": "onKeyUp", + }, + ], + "functions": [ + { + "description": "Sets input focus on the textarea control.", + "name": "focus", + "parameters": [], + "returnType": "void", + }, + { + "description": "Selects all text in the textarea control.", + "name": "select", + "parameters": [], + "returnType": "void", + }, + { + "description": "Selects a range of text in the textarea control. + +See https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement/setSelectionRange +for more details on this method. Be aware that using this method in React has some +common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-setselectionrange-incompatible-with-react-hooks", + "name": "setSelectionRange", + "parameters": [ + { + "name": "start", + "type": "number | null", + }, + { + "name": "end", + "type": "number | null", + }, + { + "name": "direction", + "type": ""none" | "forward" | "backward"", + }, + ], + "returnType": "void", + }, + ], + "name": "PromptInput", + "properties": [ + { + "description": "Adds an aria-label to the action button.", + "i18nTag": true, + "name": "actionButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "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.", + "name": "actionButtonIconAlt", + "optional": true, + "type": "string", + }, + { + "description": "Determines what icon to display in the action button.", + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "gen-ai", + "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", + "calendar", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "close", + "closed-caption", + "closed-caption-unavailable", + "copy", + "command-prompt", + "delete-marker", + "download", + "drag-indicator", + "edit", + "ellipsis", + "envelope", + "exit-full-screen", + "expand", + "external", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "file", + "filter", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "globe", + "grid-view", + "group-active", + "group", + "heart", + "heart-filled", + "history", + "insert-row", + "key", + "keyboard", + "list-view", + "location-pin", + "lock-private", + "map", + "menu", + "microphone", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "pause", + "play", + "redo", + "refresh", + "remove", + "resize-area", + "script", + "security", + "settings", + "send", + "share", + "shrink", + "slash", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-pending", + "status-positive", + "status-stopped", + "status-warning", + "subtract-minus", + "suggestions", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-in", + "zoom-out", + "zoom-to-fit", + ], + }, + "name": "actionButtonIconName", + "optional": true, + "type": "string", + }, + { + "description": "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.", + "name": "actionButtonIconUrl", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-describedby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for each element that you want to use as a description +and set the property to a string of each ID separated by spaces (for example, \`"id1 id2 id3"\`).", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds an \`aria-label\` to the native control. + +Use this if you don't have a visible label for this control.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for the element you want to use as label and set the property to that ID.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Specifies whether to add \`aria-required\` to the native control.", + "name": "ariaRequired", + "optional": true, + "type": "boolean", + }, + { + "description": "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.", + "inlineType": { + "name": "string | boolean", + "type": "union", + "values": [ + "string", + "false", + "true", + ], + }, + "name": "autoComplete", + "optional": true, + "type": "string | boolean", + }, + { + "description": "Indicates whether the control should be focused as +soon as the page loads, which enables the user to +start typing without having to manually focus the control. Don't +use this option on pages where the control may be +scrolled out of the viewport.", + "name": "autoFocus", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Adds an \`aria-label\` to the clear button inside the search input.", + "i18nTag": true, + "name": "clearAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the ID of the native form element. You can use it to relate +a label element's \`for\` attribute to this control. + +It defaults to an automatically generated ID that +is provided by its parent form field component.", + "name": "controlId", + "optional": true, + "type": "string", + }, + { + "description": "Specifies whether to disable the action button.", + "name": "disableActionButton", + "optional": true, + "type": "boolean", + }, + { + "description": "Specifies whether to disable browser autocorrect and related features. +If you set this to \`true\`, it disables any native browser capabilities +that automatically correct user input, such as \`autocorrect\` and +\`autocapitalize\`. If you don't set it, the behavior follows the default behavior +of the user's browser.", + "name": "disableBrowserAutocorrect", + "optional": true, + "type": "boolean", + }, + { + "description": "Specifies if the control is disabled, which prevents the +user from modifying the value and prevents the value from +being included in a form submission. A disabled control can't +receive focus.", + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "description": "Determines whether the secondary actions area of the input has padding. If true, removes the default padding from the secondary actions area.", + "name": "disableSecondaryActionsPaddings", + "optional": true, + "type": "boolean", + }, + { + "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", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Adds a hint to the browser about the type of data a user may enter into this field. +Some devices may render a different virtual keyboard depending on this value. +This value may not be supported by all browsers or devices.", + "inlineType": { + "name": "InputProps.InputMode", + "type": "union", + "values": [ + "search", + "none", + "url", + "text", + "tel", + "email", + "numeric", + "decimal", + ], + }, + "name": "inputMode", + "optional": true, + "type": "string", + }, + { + "description": "Overrides the invalidation state. Usually the invalid state +comes from the parent \`FormField\`component, +however sometimes you need to override its +state when you have more than one input within a +single form field.", + "name": "invalid", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "3", + "description": "Specifies the maximum number of lines of text the textarea will expand to. +Defaults to 3. Use -1 for infinite rows.", + "name": "maxRows", + "optional": true, + "type": "number", + }, + { + "defaultValue": "1", + "description": "Specifies the minimum number of lines of text to set the height to.", + "name": "minRows", + "optional": true, + "type": "number", + }, + { + "description": "Specifies the name of the control used in HTML forms.", + "name": "name", + "optional": true, + "type": "string", + }, + { + "description": "Attributes to add to the native \`textarea\` element. +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.", + "inlineType": { + "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", + "type": "union", + "values": [ + "Omit, "children">", + "Record<\`data-\${string}\`, string>", + ], + }, + "name": "nativeTextareaAttributes", + "optional": true, + "systemTags": [ + "core", + ], + "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", + }, + { + "description": "Specifies the placeholder text rendered when the value is an empty string.", + "name": "placeholder", + "optional": true, + "type": "string", + }, + { + "description": "Specifies if the control is read-only, which prevents the +user from modifying the value but includes it in a form +submission. A read-only control can receive focus. + +Don't use read-only inputs outside a form.", + "name": "readOnly", + "optional": true, + "type": "boolean", + }, + { + "description": "Specifies the value of the \`spellcheck\` attribute on the native control. +This value controls the native browser capability to check for spelling/grammar errors. +If not set, the browser default behavior is to perform spellchecking. +For more details, check the [spellcheck MDN article](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck). + +Enhanced spellchecking features of your browser and/or operating system may send input values to external parties. +Make sure it’s deactivated for fields with sensitive information to prevent +inadvertently sending data (such as user passwords) to third parties.", + "name": "spellcheck", + "optional": true, + "type": "boolean", + }, + { + "description": "The step attribute is a number that specifies the granularity that the value +must adhere to or the keyword "any". It is valid for the numeric input types, +including the date, month, week, time, datetime-local, number and range types.", + "inlineType": { + "name": "InputProps.Step", + "type": "union", + "values": [ + "number", + ""any"", + ], + }, + "name": "step", + "optional": true, + "type": "InputProps.Step", + }, + { + "description": "Specifies the type of control to render. +Inputs with a \`number\` type use the native element behavior, which might +be slightly different across browsers.", + "inlineType": { + "name": "InputProps.Type", + "type": "union", + "values": [ + "number", + "search", + "url", + "text", + "email", + "password", + ], + }, + "name": "type", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the text entered into the form element.", + "name": "value", + "optional": false, + "type": "string", + }, + { + "description": "Overrides the warning state. Usually the warning state +comes from the parent \`FormField\`component, +however sometimes you need to override its +state when you have more than one input within a +single form field. +When you use it, provide additional context with +information on the input state, and associate it +with the input using \`ariaDescribedby\`.", + "name": "warning", + "optional": true, + "type": "boolean", + }, + ], + "regions": [ + { + "description": "Specifies the SVG of a custom icon. + +Use this property if you want your custom icon to inherit colors dictated by variant or hover states. +When this property is set, the component will be decorated with \`aria-hidden="true"\`. Ensure that the \`svg\` element: +- has attribute \`focusable="false"\`. +- has \`viewBox="0 0 16 16"\`. + +If you set the \`svg\` element as the root node of the slot, the component will automatically +- set \`stroke="currentColor"\`, \`fill="none"\`, and \`vertical-align="top"\`. +- set the stroke width based on the size of the icon. +- set the width and height of the SVG element based on the size of the icon. + +If you don't want these styles to be automatically set, wrap the \`svg\` element into a \`span\`. +You can still set the stroke to \`currentColor\` to inherit the color of the surrounding elements. + +If you set both \`actionButtonIconUrl\` and \`actionButtonIconSvg\`, \`iconSvg\` will take precedence. + +*Note:* Remember to remove any additional elements (for example: \`defs\`) and related CSS classes from SVG files exported from design software. +In most cases, they aren't needed, as the \`svg\` element inherits styles from the icon component.", + "isDefault": false, + "name": "actionButtonIconSvg", + }, + { + "description": "Use this to replace the primary action. +If this is provided then any other \`actionButton*\` properties will be ignored. +Note that you should still provide an \`onAction\` function in order to handle keyboard submission.", + "isDefault": false, + "name": "customPrimaryAction", + "systemTags": [ + "core", + ], + }, + { + "description": "Use this slot to add secondary actions to the prompt input.", + "isDefault": false, + "name": "secondaryActions", + }, + { + "description": "Use this slot to add secondary content, such as file attachments, to the prompt input.", + "isDefault": false, + "name": "secondaryContent", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`definition for support-prompt-group matches the snapshot > support-prompt-group 1`] = ` { "dashCaseName": "support-prompt-group", diff --git a/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap index 98b3c0c..fec049b 100644 --- a/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -12,12 +12,14 @@ export { ElementWrapper }; import AvatarWrapper from './avatar'; import ChatBubbleWrapper from './chat-bubble'; import LoadingBarWrapper from './loading-bar'; +import PromptInputWrapper from './prompt-input'; import SupportPromptGroupWrapper from './support-prompt-group'; export { AvatarWrapper }; export { ChatBubbleWrapper }; export { LoadingBarWrapper }; +export { PromptInputWrapper }; export { SupportPromptGroupWrapper }; declare module '@cloudscape-design/test-utils-core/dist/dom' { @@ -80,6 +82,25 @@ findLoadingBar(selector?: string): LoadingBarWrapper | null; * @returns {Array} */ findAllLoadingBars(selector?: string): Array; +/** + * Returns the wrapper of the first PromptInput that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first PromptInput. + * If no matching PromptInput is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {PromptInputWrapper | null} + */ +findPromptInput(selector?: string): PromptInputWrapper | null; + +/** + * Returns an array of PromptInput wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the PromptInputs inside the current wrapper. + * If no matching PromptInput is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllPromptInputs(selector?: string): Array; /** * Returns the wrapper of the first SupportPromptGroup that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first SupportPromptGroup. @@ -142,6 +163,19 @@ ElementWrapper.prototype.findLoadingBar = function(selector) { ElementWrapper.prototype.findAllLoadingBars = function(selector) { return this.findAllComponents(LoadingBarWrapper, selector); }; +ElementWrapper.prototype.findPromptInput = function(selector) { + let rootSelector = \`.\${PromptInputWrapper.rootSelector}\`; + if("legacyRootSelector" in PromptInputWrapper && PromptInputWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${PromptInputWrapper.rootSelector}, .\${PromptInputWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, PromptInputWrapper); +}; + +ElementWrapper.prototype.findAllPromptInputs = function(selector) { + return this.findAllComponents(PromptInputWrapper, selector); +}; ElementWrapper.prototype.findSupportPromptGroup = function(selector) { let rootSelector = \`.\${SupportPromptGroupWrapper.rootSelector}\`; if("legacyRootSelector" in SupportPromptGroupWrapper && SupportPromptGroupWrapper.legacyRootSelector){ @@ -178,12 +212,14 @@ export { ElementWrapper }; import AvatarWrapper from './avatar'; import ChatBubbleWrapper from './chat-bubble'; import LoadingBarWrapper from './loading-bar'; +import PromptInputWrapper from './prompt-input'; import SupportPromptGroupWrapper from './support-prompt-group'; export { AvatarWrapper }; export { ChatBubbleWrapper }; export { LoadingBarWrapper }; +export { PromptInputWrapper }; export { SupportPromptGroupWrapper }; declare module '@cloudscape-design/test-utils-core/dist/selectors' { @@ -240,6 +276,23 @@ findLoadingBar(selector?: string): LoadingBarWrapper; * @returns {MultiElementWrapper} */ findAllLoadingBars(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the PromptInputs with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches PromptInputs. + * + * @param {string} [selector] CSS Selector + * @returns {PromptInputWrapper} + */ +findPromptInput(selector?: string): PromptInputWrapper; + +/** + * Returns a multi-element wrapper that matches PromptInputs with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches PromptInputs. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllPromptInputs(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the SupportPromptGroups with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches SupportPromptGroups. @@ -300,6 +353,19 @@ ElementWrapper.prototype.findLoadingBar = function(selector) { ElementWrapper.prototype.findAllLoadingBars = function(selector) { return this.findAllComponents(LoadingBarWrapper, selector); }; +ElementWrapper.prototype.findPromptInput = function(selector) { + let rootSelector = \`.\${PromptInputWrapper.rootSelector}\`; + if("legacyRootSelector" in PromptInputWrapper && PromptInputWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${PromptInputWrapper.rootSelector}, .\${PromptInputWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, PromptInputWrapper); +}; + +ElementWrapper.prototype.findAllPromptInputs = function(selector) { + return this.findAllComponents(PromptInputWrapper, selector); +}; ElementWrapper.prototype.findSupportPromptGroup = function(selector) { let rootSelector = \`.\${SupportPromptGroupWrapper.rootSelector}\`; if("legacyRootSelector" in SupportPromptGroupWrapper && SupportPromptGroupWrapper.legacyRootSelector){ diff --git a/src/index.tsx b/src/index.tsx index a4dfd17..799d467 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,3 +12,6 @@ export type { ChatBubbleProps } from "./chat-bubble"; export { default as SupportPromptGroup } from "./support-prompt-group"; export type { SupportPromptGroupProps } from "./support-prompt-group"; + +export { default as PromptInput } from "./prompt-input"; +export type { PromptInputProps } from "./prompt-input"; diff --git a/src/internal/base-component/get-data-attributes.ts b/src/internal/base-component/get-data-attributes.ts index 83c3078..d6a8c33 100644 --- a/src/internal/base-component/get-data-attributes.ts +++ b/src/internal/base-component/get-data-attributes.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export function getDataAttributes(props: Record) { +export function getDataAttributes(props: Record) { const result: Record = {}; Object.keys(props).forEach((prop) => { if (prop.startsWith("data-")) { diff --git a/src/internal/context/form-field-context.ts b/src/internal/context/form-field-context.ts new file mode 100644 index 0000000..4357acc --- /dev/null +++ b/src/internal/context/form-field-context.ts @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from "react"; + +export interface FormFieldControlProps { + /** + * Specifies the ID of the native form element. You can use it to relate + * a label element's `for` attribute to this control. + * + * It defaults to an automatically generated ID that + * is provided by its parent form field component. + */ + controlId?: string; + + /** + * Adds `aria-labelledby` to the component. If you're using this component within a form field, + * don't set this property because the form field component automatically sets it. + * + * Use this property if the component isn't surrounded by a form field, or you want to override the value + * automatically set by the form field (for example, if you have two components within a single form field). + * + * To use it correctly, define an ID for the element you want to use as label and set the property to that ID. + */ + ariaLabelledby?: string; + + /** + * Adds `aria-describedby` to the component. If you're using this component within a form field, + * don't set this property because the form field component automatically sets it. + * + * Use this property if the component isn't surrounded by a form field, or you want to override the value + * automatically set by the form field (for example, if you have two components within a single form field). + * + * To use it correctly, define an ID for each element that you want to use as a description + * and set the property to a string of each ID separated by spaces (for example, `"id1 id2 id3"`). + */ + ariaDescribedby?: string; +} + +export interface FormFieldCommonValidationControlProps extends FormFieldControlProps { + /** + * Overrides the invalidation state. Usually the invalid state + * comes from the parent `FormField`component, + * however sometimes you need to override its + * state when you have more than one input within a + * single form field. + */ + invalid?: boolean; +} + +export interface FormFieldValidationControlProps extends FormFieldCommonValidationControlProps { + /** + * Overrides the warning state. Usually the warning state + * comes from the parent `FormField`component, + * however sometimes you need to override its + * state when you have more than one input within a + * single form field. + * When you use it, provide additional context with + * information on the input state, and associate it + * with the input using `ariaDescribedby`. + */ + warning?: boolean; +} + +export const FormFieldContext = createContext({}); + +function applyDefault(fields: T, defaults: T, keys: (keyof T)[]) { + const result = {}; + keys.forEach((key) => { + result[key] = fields[key] === undefined ? defaults[key] : fields[key]; + }); + return result; +} + +export function useFormFieldContext(props: FormFieldValidationControlProps) { + const context = useContext(FormFieldContext); + return applyDefault(props, context, ["invalid", "warning", "controlId", "ariaLabelledby", "ariaDescribedby"]); +} diff --git a/src/internal/events/index.ts b/src/internal/events/index.ts index e0729ca..749f7ad 100644 --- a/src/internal/events/index.ts +++ b/src/internal/events/index.ts @@ -15,6 +15,16 @@ export interface ClickDetail { metaKey: boolean; } +export interface BaseKeyDetail { + key: string; + keyCode: number; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; + isComposing: boolean; +} + export class CustomEventStub { defaultPrevented = false; cancelBubble = false; @@ -62,3 +72,22 @@ export function fireNonCancelableEvent(handler: NonCancelableEventHand const event = createCustomEvent({ cancelable: false, detail }); handler(event); } + +export function fireKeyboardEvent( + handler: CancelableEventHandler | undefined, + reactEvent: React.KeyboardEvent, +) { + return fireCancelableEvent( + handler, + { + keyCode: reactEvent.keyCode, + key: reactEvent.key, + ctrlKey: reactEvent.ctrlKey, + shiftKey: reactEvent.shiftKey, + altKey: reactEvent.altKey, + metaKey: reactEvent.metaKey, + isComposing: reactEvent.nativeEvent.isComposing, + }, + reactEvent, + ); +} diff --git a/src/internal/shared.scss b/src/internal/shared.scss index 4d762dc..74118f7 100644 --- a/src/internal/shared.scss +++ b/src/internal/shared.scss @@ -4,6 +4,24 @@ */ @use "../../node_modules/@cloudscape-design/design-tokens/index.scss" as cs; +$font-weight-normal: 400; + +$control-padding-vertical: cs.$space-scaled-xxs; +$control-padding-horizontal: cs.$space-field-horizontal; +$invalid-control-left-border: 8px; +$invalid-control-left-padding: calc( + #{$control-padding-horizontal} - (#{$invalid-control-left-border} - #{cs.$border-width-field}) +); + +$box-shadow-focused-width: 2px; + +// focus for clickable elements, like buttons +$box-shadow-focused: 0 0 0 $box-shadow-focused-width cs.$color-border-item-focused; +// focus for form input elements, excluding buttons +$box-shadow-focused-light: 0 0 0 1px cs.$color-border-item-focused; +// focus for form input elements, excluding buttons +$box-shadow-focused-light-invalid: 0 0 0 cs.$space-scaled-xxxs cs.$color-border-item-focused; + @mixin focus-highlight( $gutter: 4px, $border-radius: cs.$border-radius-control-default-focus-ring, @@ -52,6 +70,20 @@ } /* stylelint-enable @cloudscape-design/no-motion-outside-of-mixin, selector-combinator-disallowed-list, selector-pseudo-class-no-unknown, selector-class-pattern */ +@mixin font-body-m { + font-size: cs.$font-size-body-m; + line-height: cs.$line-height-body-m; +} + +@mixin default-text-style { + @include font-body-m; + color: cs.$color-text-body-default; + font-weight: $font-weight-normal; + font-family: cs.$font-family-base; + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; +} + @mixin styles-reset { border-collapse: separate; border-spacing: 0; @@ -78,4 +110,103 @@ visibility: visible; white-space: normal; word-spacing: normal; + @include default-text-style; +} + +@mixin control-border-radius-full { + border-start-start-radius: cs.$border-radius-input; + border-start-end-radius: cs.$border-radius-input; + border-end-start-radius: cs.$border-radius-input; + border-end-end-radius: cs.$border-radius-input; +} + +@mixin form-readonly-element( + $background-color: cs.$color-background-input-default, + $border-color: cs.$color-background-input-disabled +) { + background-color: $background-color; + border-block: cs.$border-width-field solid $border-color; + border-inline: cs.$border-width-field solid $border-color; +} + +@mixin form-disabled-element( + $background-color: cs.$color-background-input-disabled, + $border-color: cs.$color-background-input-disabled, + $color: cs.$color-text-input-disabled, + $cursor: auto +) { + background-color: $background-color; + border-block: cs.$border-width-field solid $border-color; + border-inline: cs.$border-width-field solid $border-color; + color: $color; + cursor: $cursor; +} + +@mixin form-invalid-control { + color: cs.$color-text-status-error; + border-color: cs.$color-text-status-error; + padding-inline-start: $invalid-control-left-padding; + border-inline-start-width: $invalid-control-left-border; + &:focus { + box-shadow: $box-shadow-focused-light-invalid; + } + @content; +} + +@mixin form-warning-control { + color: cs.$color-text-status-warning; + border-color: cs.$color-text-status-warning; + padding-inline-start: $invalid-control-left-padding; + border-inline-start-width: $invalid-control-left-border; + &:focus { + box-shadow: $box-shadow-focused-light-invalid; + } + @content; +} + +// Use for form input elements, excluding buttons +// or for elements that have a light border and light background +@mixin form-focus-element( + $border-radius: cs.$border-radius-input, + $border-color: cs.$color-border-input-focused, + $box-shadow: $box-shadow-focused-light +) { + // Using a special transparent outline only visible in Windows High Contrast Mode. + // See focus-highlight above. + outline: 2px dotted transparent; + + border-block: cs.$border-width-field solid $border-color; + border-inline: cs.$border-width-field solid $border-color; + border-start-start-radius: $border-radius; + border-start-end-radius: $border-radius; + border-end-start-radius: $border-radius; + border-end-end-radius: $border-radius; + + box-shadow: $box-shadow; +} + +@mixin form-placeholder( + $color: cs.$color-text-input-placeholder, + $font-size: null, + $font-style: italic, + $font-weight: null +) { + color: $color; + @if $font-size { + font-size: $font-size; + } + font-style: $font-style; + @if $font-weight { + font-weight: $font-weight; + } +} + +@mixin form-placeholder-disabled($color: cs.$color-text-input-disabled) { + color: $color; +} + +@mixin text-flex-wrapping { + word-wrap: break-word; + max-inline-size: 100%; + overflow: hidden; } diff --git a/src/internal/types.ts b/src/internal/types.ts new file mode 100644 index 0000000..43587ab --- /dev/null +++ b/src/internal/types.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type SomeRequired = T & Required>; diff --git a/src/internal/utils/convert-auto-complete.ts b/src/internal/utils/convert-auto-complete.ts new file mode 100644 index 0000000..5e5f83b --- /dev/null +++ b/src/internal/utils/convert-auto-complete.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Converts the boolean or string value of the `autoComplete` property to the correct `autocomplete` attribute value. + */ +export const convertAutoComplete = (propertyValue: boolean | string = false): string => { + if (propertyValue === true) { + return "on"; + } + return propertyValue || "off"; +}; diff --git a/src/internal/utils/pointer-events-mock.ts b/src/internal/utils/pointer-events-mock.ts new file mode 100644 index 0000000..37c493d --- /dev/null +++ b/src/internal/utils/pointer-events-mock.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export class PointerEventMock extends MouseEvent { + readonly pointerType: string; + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.pointerType = props.pointerType ?? "mouse"; + } +} diff --git a/src/internal/utils/with-native-attributes.tsx b/src/internal/utils/with-native-attributes.tsx new file mode 100644 index 0000000..50cd8a9 --- /dev/null +++ b/src/internal/utils/with-native-attributes.tsx @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { forwardRef, HTMLAttributes, ReactNode, Ref } from "react"; +import clsx from "clsx"; + +import { warnOnce } from "@cloudscape-design/component-toolkit/internal"; + +export type NativeAttributes> = + | (Omit & Record<`data-${string}`, string>) + | undefined; + +export type SkipWarnings = boolean | string[]; + +type NativeAttributesProps> = { + tag: string; + children?: ReactNode; + skipWarnings?: SkipWarnings; + nativeAttributes: NativeAttributes; + componentName: string; +} & NativeAttributes; +interface ForwardRefType { + >( + props: NativeAttributesProps & { ref?: Ref }, + ): JSX.Element; +} + +export function processAttributes>( + rest: Omit, "children" | "tag" | "skipWarnings" | "componentName" | "nativeAttributes">, + nativeAttributes: NativeAttributes, + componentName: string, + skipWarnings?: SkipWarnings, +) { + return Object.entries(nativeAttributes || {}).reduce( + (acc, [key, value]) => { + // concatenate className + if (key === "className") { + acc[key] = clsx(rest.className, value); + + // merge style + } else if (key === "style") { + acc[key] = { ...rest.style, ...value }; + + // chain event handlers + } else if (key.match(/^on[A-Z]/) && typeof value === "function" && key in rest) { + acc[key] = (event: Event) => { + value(event); + if (!event.defaultPrevented) { + (rest as any)[key](event); + } + }; + + // override other attributes, warning if it already exists + } else { + if (key in rest && (!skipWarnings || (skipWarnings !== true && !skipWarnings.includes(key)))) { + warnOnce(componentName, `Overriding native attribute [${key}] which has a Cloudscape-provided value`); + } + acc[key] = value; + } + return acc; + }, + { ...rest } as any, + ); +} + +export default forwardRef( + >( + { tag, nativeAttributes, children, skipWarnings, componentName, ...rest }: NativeAttributesProps, + ref: Ref, + ) => { + const Tag = tag; + + const processedAttributes = processAttributes(rest, nativeAttributes, componentName, skipWarnings); + + return ( + + {children} + + ); + }, +) as ForwardRefType; diff --git a/src/prompt-input/__tests__/prompt-input.test.tsx b/src/prompt-input/__tests__/prompt-input.test.tsx new file mode 100644 index 0000000..0088511 --- /dev/null +++ b/src/prompt-input/__tests__/prompt-input.test.tsx @@ -0,0 +1,458 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createRef, RefAttributes } from "react"; +import { act, render, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; + +import { KeyCode } from "../../../lib/components/internal/keycode"; +import PromptInput, { PromptInputProps } from "../../../lib/components/prompt-input"; +import createWrapper from "../../../lib/components/test-utils/dom"; +import PromptInputWrapper from "../../../lib/components/test-utils/dom/prompt-input"; + +import styles from "../../../lib/components/prompt-input/styles.selectors.js"; + +vi.mock("@cloudscape-design/component-toolkit", async () => ({ + ...(await vi.importActual("@cloudscape-design/component-toolkit")), + useContainerQuery: () => [800, () => {}], +})); + +const renderPromptInput = (promptInputProps: PromptInputProps & RefAttributes) => { + const { container } = render(); + return { wrapper: new PromptInputWrapper(container)!, container }; +}; + +describe("value", () => { + test("can be set", () => { + const { wrapper } = renderPromptInput({ value: "value" }); + expect(wrapper.getElement()).toHaveTextContent("value"); + }); + test("can be obtained through getTextareaValue API", () => { + const { wrapper } = renderPromptInput({ value: "value" }); + expect(wrapper.getTextareaValue()).toBe("value"); + }); +}); + +describe("ref", () => { + test("can be used to focus the component", () => { + const ref = createRef(); + const { wrapper } = renderPromptInput({ value: "", ref }); + expect(document.activeElement).not.toBe(wrapper.findNativeTextarea().getElement()); + ref.current?.focus(); + expect(document.activeElement).toBe(wrapper.findNativeTextarea().getElement()); + }); + + test("can be used to select all text", () => { + const ref = createRef(); + const { wrapper } = renderPromptInput({ value: "Test", ref }); + const textarea = wrapper.findNativeTextarea().getElement(); + + // Make sure no text is selected + textarea.selectionStart = textarea.selectionEnd = 0; + textarea.blur(); + expect(textarea.selectionStart).toBe(0); + expect(textarea.selectionEnd).toBe(0); + + // Select all text + ref.current!.select(); + expect(textarea.selectionStart).toBe(0); + expect(textarea.selectionEnd).toBe(4); + }); + + test("can be used to select a range", () => { + const ref = createRef(); + const { wrapper } = renderPromptInput({ value: "Test", ref }); + const textarea = wrapper.findNativeTextarea().getElement(); + + // Make sure no text is selected + textarea.selectionStart = textarea.selectionEnd = 0; + textarea.blur(); + expect(textarea.selectionStart).toBe(0); + expect(textarea.selectionEnd).toBe(0); + + // Select all text + ref.current!.setSelectionRange(1, 3); + expect(textarea.selectionStart).toBe(1); + expect(textarea.selectionEnd).toBe(3); + }); +}); + +describe("disableBrowserAutocorrect", () => { + test("does not modify autocorrect features by default", () => { + const { wrapper } = renderPromptInput({ value: "" }); + const textarea = wrapper.findNativeTextarea().getElement(); + expect(textarea).not.toHaveAttribute("autocorrect"); + expect(textarea).not.toHaveAttribute("autocapitalize"); + }); + + test("does not modify autocorrect features when falsy", () => { + const { wrapper } = renderPromptInput({ + value: "", + disableBrowserAutocorrect: false, + }); + const textarea = wrapper.findNativeTextarea().getElement(); + + expect(textarea).not.toHaveAttribute("autocorrect"); + expect(textarea).not.toHaveAttribute("autocapitalize"); + }); + + test("can disable autocorrect features when set", () => { + const { wrapper } = renderPromptInput({ + value: "", + disableBrowserAutocorrect: true, + }); + const textarea = wrapper.findNativeTextarea().getElement(); + + expect(textarea).toHaveAttribute("autocorrect", "off"); + expect(textarea).toHaveAttribute("autocapitalize", "off"); + }); +}); + +describe("autocomplete", () => { + test("is disabled by default", () => { + const { wrapper } = renderPromptInput({ value: "" }); + const textarea = wrapper.findNativeTextarea().getElement(); + expect(textarea).toHaveAttribute("autocomplete", "off"); + }); + + test("can be enabled", () => { + const { wrapper } = renderPromptInput({ value: "", autoComplete: true }); + const textarea = wrapper.findNativeTextarea().getElement(); + + expect(textarea).toHaveAttribute("autocomplete", "on"); + }); +}); + +describe("action button", () => { + test("not present if not added to props", () => { + const { wrapper } = renderPromptInput({ value: "" }); + expect(wrapper.findActionButton()).not.toBeInTheDocument(); + }); + + test("present when added", () => { + const { wrapper } = renderPromptInput({ + value: "", + actionButtonIconName: "send", + }); + expect(wrapper.findActionButton().getElement()).toBeInTheDocument(); + }); + + test("should not find primary button within secondaryActions", () => { + const { wrapper } = renderPromptInput({ + value: "", + minRows: 4, + secondaryActions: "secondary actions", + actionButtonIconName: "send", + }); + + const secondaryActionsContainer = wrapper.findSecondaryActions()!.getElement(); + const actionButton = within(secondaryActionsContainer).queryByRole("button"); + + expect(actionButton).toBeFalsy(); + }); + + test("disabled when in disabled state", () => { + const { wrapper } = renderPromptInput({ + value: "", + actionButtonIconName: "send", + disabled: true, + }); + expect(wrapper.findActionButton().getElement()).toHaveAttribute("disabled"); + }); + + test("adds aria disabled but not disabled attribute when in read-only state", () => { + const { wrapper } = renderPromptInput({ + value: "", + actionButtonIconName: "send", + readOnly: true, + }); + + expect(wrapper.findActionButton().getElement()).toHaveAttribute("aria-disabled", "true"); + expect(wrapper.findActionButton().getElement()).not.toHaveAttribute("disabled"); + }); +}); + +describe("custom primary action", () => { + test("customPrimaryAction can be provided", () => { + const { wrapper } = renderPromptInput({ + value: "", + actionButtonIconName: "send", + customPrimaryAction: ( + <> + + + + ), + }); + expect(wrapper.findCustomPrimaryAction()!.getElement().querySelectorAll("button").length).toBe(2); + }); + test("default primary action is removed if custom primaryAction provided", () => { + const { wrapper } = renderPromptInput({ + value: "", + actionButtonIconName: "send", + customPrimaryAction: "custom content", + }); + expect(wrapper.findActionButton()).toBeFalsy(); + }); +}); + +describe("prompt input in form", () => { + function renderPromptInputInForm(props: PromptInputProps = { value: "", actionButtonIconName: "send" }) { + const submitSpy = vi.fn(); + const renderResult = render( +
+ + , + ); + const promptInputWrapper = createWrapper(renderResult.container).findPromptInput()!; + return [promptInputWrapper, submitSpy] as const; + } + + beforeEach(() => { + // JSDOM prints an error message to browser logs when form attempted to submit + // https://github.com/jsdom/jsdom/issues/1937 + // We use it as an assertion + vi.spyOn(console, "error").mockImplementation(() => { + /*do not print anything to browser logs*/ + }); + }); + + afterEach(() => { + expect(console.error).not.toHaveBeenCalled(); + }); + + test("should submit the form when clicking the action button", () => { + const [wrapper, submitSpy] = renderPromptInputInForm(); + wrapper.findActionButton().click(); + expect(submitSpy).toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Not implemented: HTMLFormElement.prototype.requestSubmit"), + undefined, + ); + vi.mocked(console.error).mockClear(); + }); + + test("enter key submits form", () => { + const [wrapper, submitSpy] = renderPromptInputInForm({ value: "" }); + wrapper.findNativeTextarea().keydown(KeyCode.enter); + expect(submitSpy).toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Not implemented: HTMLFormElement.prototype.requestSubmit"), + undefined, + ); + vi.mocked(console.error).mockClear(); + }); + + test("enter key during IME composition does not submit form", () => { + const [wrapper, submitSpy] = renderPromptInputInForm({ value: "" }); + wrapper.findNativeTextarea().keydown({ keyCode: KeyCode.enter, isComposing: true }); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + test("cancelling key event prevents submission", () => { + const [wrapper, submitSpy] = renderPromptInputInForm({ + value: "", + onKeyDown: (event) => event.preventDefault(), + }); + wrapper.findNativeTextarea().keydown(KeyCode.enter); + expect(submitSpy).not.toHaveBeenCalled(); + }); +}); + +describe("events", () => { + test("fire a change event with correct parameters", () => { + const onChange = vi.fn(); + const { wrapper } = renderPromptInput({ + value: "value", + onChange: (event) => onChange(event.detail), + }); + + wrapper.setTextareaValue("updated value"); + + expect(onChange).toHaveBeenCalledWith({ value: "updated value" }); + }); + + test("fire an action event on action button click with correct parameters", () => { + const onAction = vi.fn(); + const { wrapper } = renderPromptInput({ + value: "value", + actionButtonIconName: "send", + onAction: (event) => onAction(event.detail), + }); + + wrapper.findActionButton().click(); + expect(onAction).toHaveBeenCalled(); + }); + + test("fire an action event on enter keydown with correct parameters", () => { + const onAction = vi.fn(); + const { wrapper } = renderPromptInput({ + value: "value", + actionButtonIconName: "send", + onAction: (event) => onAction(event.detail), + }); + + wrapper.findNativeTextarea().keydown(KeyCode.enter); + expect(onAction).toHaveBeenCalled(); + }); + + test("does not fire an action event on enter keydown if part of IME composition", () => { + const onAction = vi.fn(); + const { wrapper } = renderPromptInput({ + value: "value", + actionButtonIconName: "send", + onAction: (event) => onAction(event.detail), + }); + + wrapper.findNativeTextarea().keydown({ keyCode: KeyCode.enter, isComposing: true }); + expect(onAction).not.toHaveBeenCalled(); + }); + + test("fire keydown event", () => { + const onKeyDown = vi.fn(); + const { wrapper } = renderPromptInput({ + value: "value", + actionButtonIconName: "send", + onKeyDown: (event) => onKeyDown(event.detail), + }); + + act(() => { + wrapper.findNativeTextarea().keydown(KeyCode.enter); + }); + + expect(onKeyDown).toHaveBeenCalled(); + }); +}); + +describe("min and max rows", () => { + test("defaults to 1", () => { + const { wrapper } = renderPromptInput({ value: "" }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("rows", "1"); + }); + + test("updates based on min row property", () => { + const { wrapper } = renderPromptInput({ value: "", minRows: 4 }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("rows", "4"); + }); + + test("does not update based on max row property", () => { + const { wrapper } = renderPromptInput({ value: "", maxRows: 4 }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("rows", "1"); + }); + + test("does not update when max rows is set to -1", () => { + const ref = createRef(); + const { wrapper } = renderPromptInput({ value: "", maxRows: -1, ref }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("rows", "1"); + }); +}); + +describe("secondary actions", () => { + test("should render correct text in secondary actions slot", () => { + const { wrapper } = renderPromptInput({ + value: "", + minRows: 4, + secondaryActions: "secondary actions", + }); + + expect(wrapper.findSecondaryActions()?.getElement()).toHaveTextContent("secondary actions"); + }); +}); + +test("clicking the area between secondary actions and action button should focus the component", () => { + const { wrapper } = renderPromptInput({ + value: "", + secondaryActions: "secondary actions", + }); + + wrapper.find(`.${styles.buffer}`)!.click(); + + expect(wrapper.findNativeTextarea().getElement()).toHaveFocus(); +}); + +describe("secondary content", () => { + test("should render correct text in secondary content slot", () => { + const { wrapper } = renderPromptInput({ + value: "", + minRows: 4, + secondaryContent: "secondary content", + }); + + expect(wrapper.findSecondaryContent()?.getElement()).toHaveTextContent("secondary content"); + }); +}); + +describe("a11y", () => { + describe("aria-label", () => { + test("is not added if not defined", () => { + const { wrapper } = renderPromptInput({ value: "" }); + expect(wrapper.findNativeTextarea().getElement()).not.toHaveAttribute("aria-label"); + }); + test("can be set to custom value", () => { + const { wrapper } = renderPromptInput({ + value: "", + ariaLabel: "my-custom-label", + }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("aria-label", "my-custom-label"); + }); + test("is added to the region wrapper", () => { + const { wrapper } = renderPromptInput({ + value: "", + ariaLabel: "my-custom-label", + }); + expect(within(wrapper.getElement()).getByRole("region")).toHaveAttribute("aria-label", "my-custom-label"); + }); + }); + + describe("aria-describedby", () => { + test("is not added if set to null", () => { + const { wrapper } = renderPromptInput({ value: "" }); + expect(wrapper.findNativeTextarea().getElement()).not.toHaveAttribute("aria-describedby"); + }); + test("can be set to custom value", () => { + const { wrapper } = renderPromptInput({ + value: "", + ariaDescribedby: "my-custom-id", + }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("aria-describedby", "my-custom-id"); + }); + test("can be customized without controlId", () => { + const { wrapper } = renderPromptInput({ + value: "", + controlId: undefined, + ariaDescribedby: "my-custom-id", + }); + + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("aria-describedby", "my-custom-id"); + }); + }); + + describe("aria-labelledby", () => { + test("is not added if not defined", () => { + const { wrapper } = renderPromptInput({ value: "" }); + expect(wrapper.findNativeTextarea().getElement()).not.toHaveAttribute("aria-labelledby"); + }); + test("can be set to custom value", () => { + const { wrapper } = renderPromptInput({ + value: "", + ariaLabelledby: "my-custom-id", + }); + expect(wrapper.findNativeTextarea().getElement()).toHaveAttribute("aria-labelledby", "my-custom-id"); + }); + }); +}); + +describe("native attributes", () => { + it("adds native attributes", () => { + const { container } = renderPromptInput({ value: "", nativeTextareaAttributes: { "data-testid": "my-test-id" } }); + expect(container.querySelectorAll('[data-testid="my-test-id"]')).toHaveLength(1); + expect(container.querySelectorAll('textarea[data-testid="my-test-id"]')).toHaveLength(1); + }); + it("concatenates class names", () => { + const { container } = renderPromptInput({ value: "", nativeTextareaAttributes: { className: "additional-class" } }); + const textarea = container.querySelector("textarea")!; + expect(textarea).toHaveClass(styles.textarea); + expect(textarea).toHaveClass("additional-class"); + }); +}); diff --git a/src/prompt-input/index.tsx b/src/prompt-input/index.tsx new file mode 100644 index 0000000..d8a1426 --- /dev/null +++ b/src/prompt-input/index.tsx @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +"use client"; +import { forwardRef, type Ref } from "react"; + +import useBaseComponent from "../internal/base-component/use-base-component"; +import { applyDisplayName } from "../internal/utils/apply-display-name"; +import { PromptInputProps } from "./interfaces"; +import InternalPromptInput from "./internal"; + +export type { PromptInputProps }; + +const PromptInput = forwardRef( + ( + { + autoComplete, + autoFocus, + disableBrowserAutocorrect, + disableActionButton, + spellcheck, + readOnly, + actionButtonIconName, + minRows = 1, + maxRows = 3, + ...props + }: PromptInputProps, + ref: Ref, + ) => { + const baseComponentProps = useBaseComponent("PromptInput", { + props: { + readOnly, + autoComplete, + autoFocus, + disableBrowserAutocorrect, + disableActionButton, + spellcheck, + actionButtonIconName, + minRows, + maxRows, + }, + }); + return ( + + ); + }, +); +applyDisplayName(PromptInput, "PromptInput"); +export default PromptInput; diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts new file mode 100644 index 0000000..617e339 --- /dev/null +++ b/src/prompt-input/interfaces.ts @@ -0,0 +1,203 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { IconProps } from "@cloudscape-design/components/icon"; +import { InputProps } from "@cloudscape-design/components/input"; + +import { FormFieldValidationControlProps } from "../internal/context/form-field-context"; +import { BaseKeyDetail, CancelableEventHandler, NonCancelableEventHandler } from "../internal/events"; +/** + * @awsuiSystem core + */ +import { NativeAttributes } from "../internal/utils/with-native-attributes"; + +export interface InputAutoCorrect { + /** + * Specifies whether to disable browser autocorrect and related features. + * If you set this to `true`, it disables any native browser capabilities + * that automatically correct user input, such as `autocorrect` and + * `autocapitalize`. If you don't set it, the behavior follows the default behavior + * of the user's browser. + */ + disableBrowserAutocorrect?: boolean; +} + +export interface InputAutoComplete { + /** + * 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. + */ + autoComplete?: boolean | string; +} + +export interface InputSpellcheck { + /** + * Specifies the value of the `spellcheck` attribute on the native control. + * This value controls the native browser capability to check for spelling/grammar errors. + * If not set, the browser default behavior is to perform spellchecking. + * For more details, check the [spellcheck MDN article](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck). + * + * Enhanced spellchecking features of your browser and/or operating system may send input values to external parties. + * Make sure it’s deactivated for fields with sensitive information to prevent + * inadvertently sending data (such as user passwords) to third parties. + */ + spellcheck?: boolean; +} + +export interface InputKeyEvents { + /** + * Called when the underlying native textarea emits a `keydown` event. + * The event `detail` contains the `keyCode` and information + * about modifiers (that is, CTRL, ALT, SHIFT, META, etc.). + */ + onKeyDown?: CancelableEventHandler; + + /** + * Called when the underlying native textarea emits a `keyup` event. + * The event `detail` contains the `keyCode` and information + * about modifiers (that is, CTRL, ALT, SHIFT, META, etc.). + */ + onKeyUp?: CancelableEventHandler; +} + +export interface PromptInputProps + extends Omit, + InputKeyEvents, + InputAutoCorrect, + InputAutoComplete, + InputSpellcheck, + FormFieldValidationControlProps { + /** + * Called whenever a user clicks the action button or presses the "Enter" key. + * The event `detail` contains the current value of the field. + */ + 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. + * + * Use this property if you want your custom icon to inherit colors dictated by variant or hover states. + * When this property is set, the component will be decorated with `aria-hidden="true"`. Ensure that the `svg` element: + * - has attribute `focusable="false"`. + * - has `viewBox="0 0 16 16"`. + * + * If you set the `svg` element as the root node of the slot, the component will automatically + * - set `stroke="currentColor"`, `fill="none"`, and `vertical-align="top"`. + * - set the stroke width based on the size of the icon. + * - set the width and height of the SVG element based on the size of the icon. + * + * If you don't want these styles to be automatically set, wrap the `svg` element into a `span`. + * You can still set the stroke to `currentColor` to inherit the color of the surrounding elements. + * + * If you set both `actionButtonIconUrl` and `actionButtonIconSvg`, `iconSvg` will take precedence. + * + * *Note:* Remember to remove any additional elements (for example: `defs`) and related CSS classes from SVG files exported from design software. + * 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 + */ + actionButtonAriaLabel?: string; + + /** + * Specifies whether to disable the action button. + */ + disableActionButton?: boolean; + + /** + * Specifies the minimum number of lines of text to set the height to. + */ + minRows?: number; + + /** + * Specifies the maximum number of lines of text the textarea will expand to. + * Defaults to 3. Use -1 for infinite rows. + */ + maxRows?: number; + + /** + * Use this to replace the primary action. + * If this is provided then any other `actionButton*` properties will be ignored. + * Note that you should still provide an `onAction` function in order to handle keyboard submission. + * + * @awsuiSystem core + */ + customPrimaryAction?: React.ReactNode; + + /** + * Use this slot to add secondary actions to the prompt input. + */ + secondaryActions?: React.ReactNode; + + /** + * Use this slot to add secondary content, such as file attachments, to the prompt input. + */ + secondaryContent?: React.ReactNode; + + /** + * Determines whether the secondary actions area of the input has padding. If true, removes the default padding from the secondary actions area. + */ + disableSecondaryActionsPaddings?: boolean; + + /** + * Determines whether the secondary content area of the input has padding. If true, removes the default padding from the secondary content area. + */ + disableSecondaryContentPaddings?: boolean; + + /** + * Attributes to add to the native `textarea` element. + * 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. + * + * @awsuiSystem core + */ + nativeTextareaAttributes?: NativeAttributes>; +} + +export namespace PromptInputProps { + export type KeyDetail = BaseKeyDetail; + export type ActionDetail = InputProps.ChangeDetail; + + export interface Ref { + /** + * Sets input focus on the textarea control. + */ + focus(): void; + + /** + * Selects all text in the textarea control. + */ + select(): void; + + /** + * Selects a range of text in the textarea control. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLTextAreaElement/setSelectionRange + * for more details on this method. Be aware that using this method in React has some + * 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; + } +} diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx new file mode 100644 index 0000000..bc2850b --- /dev/null +++ b/src/prompt-input/internal.tsx @@ -0,0 +1,264 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + ChangeEvent, + forwardRef, + KeyboardEvent, + Ref, + TextareaHTMLAttributes, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from "react"; +import clsx from "clsx"; + +import { useDensityMode } from "@cloudscape-design/component-toolkit/internal"; +import Button from "@cloudscape-design/components/button"; +import { lineHeightBodyM, spaceScaledXxxs, spaceStaticXxs } from "@cloudscape-design/design-tokens"; + +import { getDataAttributes } from "../internal/base-component/get-data-attributes"; +import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; +import { useFormFieldContext } from "../internal/context/form-field-context"; +import { fireKeyboardEvent, fireNonCancelableEvent } from "../internal/events"; +import { SomeRequired } from "../internal/types"; +import WithNativeAttributes from "../internal/utils/with-native-attributes"; +import { PromptInputProps } from "./interfaces"; +import { convertAutoComplete } from "./utils"; + +import styles from "./styles.css.js"; +import testutilStyles from "./test-classes/styles.css.js"; + +interface InternalPromptInputProps + extends SomeRequired, + InternalBaseComponentProps {} + +const InternalPromptInput = forwardRef( + ( + { + value, + actionButtonAriaLabel, + actionButtonIconName, + actionButtonIconUrl, + actionButtonIconSvg, + actionButtonIconAlt, + ariaLabel, + autoComplete, + autoFocus, + disableActionButton, + disableBrowserAutocorrect, + disabled, + maxRows, + minRows, + name, + onAction, + onBlur, + onChange, + onFocus, + onKeyDown, + onKeyUp, + placeholder, + readOnly, + spellcheck, + customPrimaryAction, + secondaryActions, + secondaryContent, + disableSecondaryActionsPaddings, + disableSecondaryContentPaddings, + nativeTextareaAttributes, + __internalRootRef, + ...rest + }: InternalPromptInputProps, + ref: Ref, + ) => { + const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = useFormFieldContext(rest); + + const textareaRef = useRef(null); + + const isCompactMode = useDensityMode(textareaRef) === "compact"; + + const PADDING = spaceStaticXxs; + const LINE_HEIGHT = lineHeightBodyM; + const DEFAULT_MAX_ROWS = 3; + + useImperativeHandle( + ref, + () => ({ + focus(...args: Parameters) { + textareaRef.current?.focus(...args); + }, + select() { + textareaRef.current?.select(); + }, + setSelectionRange(...args: Parameters) { + textareaRef.current?.setSelectionRange(...args); + }, + }), + [textareaRef], + ); + + const handleKeyDown = (event: KeyboardEvent) => { + fireKeyboardEvent(onKeyDown, event); + + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + if (event.currentTarget.form && !event.isDefaultPrevented()) { + event.currentTarget.form.requestSubmit(); + } + event.preventDefault(); + fireNonCancelableEvent(onAction, { value: value || "" }); + } + }; + + const handleChange = (event: ChangeEvent) => { + fireNonCancelableEvent(onChange, { value: event.target.value }); + adjustTextareaHeight(); + }; + + const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; + + 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"; + + const minTextareaHeight = `calc(${LINE_HEIGHT} + ${spaceScaledXxxs} * 2)`; // the min height of Textarea with 1 row + + 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})`; + } + } + }, [maxRows, LINE_HEIGHT, PADDING]); + + useEffect(() => { + const handleResize = () => { + adjustTextareaHeight(); + }; + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [adjustTextareaHeight]); + + useEffect(() => { + adjustTextareaHeight(); + }, [value, adjustTextareaHeight, maxRows, isCompactMode]); + + const attributes: TextareaHTMLAttributes = { + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + "aria-describedby": ariaDescribedby, + "aria-invalid": invalid ? "true" : undefined, + name, + placeholder, + autoFocus, + className: clsx(styles.textarea, testutilStyles.textarea, { + [styles.invalid]: invalid, + [styles.warning]: warning, + }), + autoComplete: convertAutoComplete(autoComplete), + 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, + onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), + onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), + }; + + if (disableBrowserAutocorrect) { + attributes.autoCorrect = "off"; + attributes.autoCapitalize = "off"; + } + + const action = ( +
+ {customPrimaryAction ?? ( +
+ ); + + return ( +
+ {secondaryContent && ( +
+ {secondaryContent} +
+ )} +
+ + {hasActionButton && !secondaryActions && action} +
+ {secondaryActions && ( +
+
+ {secondaryActions} +
+
textareaRef.current?.focus()} /> + {hasActionButton && action} +
+ )} +
+ ); + }, +); + +export default InternalPromptInput; diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss new file mode 100644 index 0000000..2efb945 --- /dev/null +++ b/src/prompt-input/styles.scss @@ -0,0 +1,194 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +/* stylelint-disable */ + +@use "../../node_modules/@cloudscape-design/design-tokens/index.scss" as awsui; +@use "../internal/shared" as shared; + +$send-icon-right-spacing: awsui.$space-static-xxs; + +.root { + @include shared.styles-reset; + @include shared.control-border-radius-full(); + cursor: text; + + background-color: awsui.$color-background-input-default; + + border-block: awsui.$border-width-field solid awsui.$color-border-input-default; + border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; + + &.textarea-readonly { + @include shared.form-readonly-element; + } + + &.disabled { + @include shared.form-disabled-element; + cursor: default; + } + + &.textarea-invalid { + @include shared.form-invalid-control(); + & { + padding-inline-start: 0; + } + + &:focus-within, + &:focus { + @include shared.form-invalid-control(); + & { + padding-inline-start: 0; + box-shadow: shared.$box-shadow-focused-light-invalid; + } + } + } + + &.textarea-warning { + @include shared.form-warning-control(); + & { + padding-inline-start: 0; + } + + &:focus-within, + &:focus { + @include shared.form-warning-control(); + & { + padding-inline-start: 0; + box-shadow: shared.$box-shadow-focused-light-invalid; + } + } + } + + &:focus-within, + &:focus { + @include shared.form-focus-element; + } +} + +.textarea { + @include shared.styles-reset; + @include shared.control-border-radius-full(); + @include shared.font-body-m; + // Restore browsers' default resize values + resize: none; + // Restore default text cursor + cursor: text; + // Allow multi-line placeholders + white-space: pre-wrap; + background-color: inherit; + + padding-block: shared.$control-padding-vertical; + padding-inline: shared.$control-padding-horizontal; + + color: awsui.$color-text-body-default; + max-inline-size: 100%; + inline-size: 100%; + display: block; + box-sizing: border-box; + + border: 0; + + &::placeholder { + @include shared.form-placeholder; + opacity: 1; + } + + + &:focus { + outline: none; + } + + &:invalid { + // discard built-in invalid styles, customers should use `invalid` property only (AWSUI-3947) + box-shadow: none; + } + + &.invalid, + &.warning { + padding-inline-start: shared.$invalid-control-left-padding; + } + + &:disabled { + @include shared.form-disabled-element; + border: 0; + cursor: default; + + &::placeholder { + @include shared.form-placeholder-disabled; + opacity: 1; + } + + } + + &-wrapper { + display: flex; + } +} + +.primary-action { + align-self: flex-end; + flex-shrink: 0; + padding-inline-start: calc(shared.$control-padding-horizontal / 2); + + .textarea-wrapper > & { + padding-inline-end: calc(shared.$control-padding-horizontal / 2); + + > .action-button { + margin-block-end: awsui.$space-scaled-xxxs; + padding: 0; + } + } +} + +.secondary-content { + @include shared.styles-reset; + @include shared.control-border-radius-full(); + + &.with-paddings { + padding-block-start: awsui.$space-scaled-s; + padding-block-end: awsui.$space-scaled-s; + padding-inline-start: shared.$control-padding-horizontal; + padding-inline-end: shared.$control-padding-horizontal; + + &.invalid, + &.warning { + padding-inline-start: shared.$invalid-control-left-padding; + } + } +} + +.action-stripe { + @include shared.styles-reset; + @include shared.control-border-radius-full(); + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.secondary-actions { + flex-basis: max-content; + flex-grow: 0; + flex-shrink: 1; + box-sizing: border-box; + @include shared.text-flex-wrapping; + &.with-paddings { + padding-inline: shared.$control-padding-horizontal; + padding-block-start: awsui.$space-scaled-s; + padding-block-end: shared.$control-padding-vertical; + + &.invalid, + &.warning { + padding-inline-start: shared.$invalid-control-left-padding; + } + } + &.with-paddings-and-actions { + padding-inline-end: 0; + } +} + +.buffer { + flex: 1; + align-self: stretch; + cursor: text; +} \ No newline at end of file diff --git a/src/prompt-input/test-classes/styles.scss b/src/prompt-input/test-classes/styles.scss new file mode 100644 index 0000000..a395897 --- /dev/null +++ b/src/prompt-input/test-classes/styles.scss @@ -0,0 +1,27 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +.root { + /* used in test-utils */ +} + +.textarea { + /* used in test-utils */ +} + +.action-button { + /* used in test-utils */ +} + +.primary-action { + /* used in test-utils */ +} + +.secondary-actions { + /* used in test-utils */ +} + +.secondary-content { + /* used in test-utils */ +} diff --git a/src/prompt-input/utils.ts b/src/prompt-input/utils.ts new file mode 100644 index 0000000..5e5f83b --- /dev/null +++ b/src/prompt-input/utils.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Converts the boolean or string value of the `autoComplete` property to the correct `autocomplete` attribute value. + */ +export const convertAutoComplete = (propertyValue: boolean | string = false): string => { + if (propertyValue === true) { + return "on"; + } + return propertyValue || "off"; +}; diff --git a/src/test-utils/dom/prompt-input/index.ts b/src/test-utils/dom/prompt-input/index.ts new file mode 100644 index 0000000..73e915a --- /dev/null +++ b/src/test-utils/dom/prompt-input/index.ts @@ -0,0 +1,60 @@ +// 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 { act, setNativeValue } from "@cloudscape-design/test-utils-core/utils-dom"; + +import testutilStyles from "../../../prompt-input/test-classes/styles.selectors.js"; + +export default class PromptInputWrapper extends ComponentWrapper { + static rootSelector = testutilStyles.root; + + findNativeTextarea(): ElementWrapper { + return this.findByClassName(testutilStyles.textarea)!; + } + + /** + * Finds the action button. Note that, despite its typings, this may return null. + */ + findActionButton(): ElementWrapper { + return this.findByClassName(testutilStyles["action-button"])!; + } + + /** + * Finds the secondary actions slot. Note that, despite its typings, this may return null. + */ + findSecondaryActions(): ElementWrapper { + return this.findByClassName(testutilStyles["secondary-actions"])!; + } + + findSecondaryContent(): ElementWrapper | null { + return this.findByClassName(testutilStyles["secondary-content"]); + } + + findCustomPrimaryAction(): ElementWrapper | null { + return this.findByClassName(testutilStyles["primary-action"]); + } + + /** + * Gets the value of the component. + * + * Returns the current value of the textarea. + */ + @usesDom getTextareaValue(): string { + return this.findNativeTextarea().getElement().value; + } + + /** + * 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(); + act(() => { + const event = new Event("change", { bubbles: true, cancelable: false }); + setNativeValue(element, value); + element.dispatchEvent(event); + }); + } +} diff --git a/src/test-utils/types/global.d.ts b/src/test-utils/types/global.d.ts index 883fef8..b230ab7 100644 --- a/src/test-utils/types/global.d.ts +++ b/src/test-utils/types/global.d.ts @@ -8,3 +8,8 @@ declare module "*.selectors.js" { const styles: Record; export default styles; } + +declare module "*.png" { + const value: string; + export default value; +} diff --git a/types/global.d.ts b/types/global.d.ts index 883fef8..b230ab7 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -8,3 +8,8 @@ declare module "*.selectors.js" { const styles: Record; export default styles; } + +declare module "*.png" { + const value: string; + export default value; +}