diff --git a/package.json b/package.json index 649cd53..731ad97 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "./avatar": "./avatar/index.js", "./chat-bubble": "./chat-bubble/index.js", "./loading-bar": "./loading-bar/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", "./internal/api-docs/*.js": "./internal/api-docs/*.js" @@ -120,7 +121,7 @@ ], "*.{scss,css}": [ "stylelint --fix" - ], + ], "package-lock.json": [ "./scripts/prepare-package-lock.js" ] diff --git a/pages/support-prompt-group/in-context.page.tsx b/pages/support-prompt-group/in-context.page.tsx new file mode 100644 index 0000000..fa923a5 --- /dev/null +++ b/pages/support-prompt-group/in-context.page.tsx @@ -0,0 +1,147 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +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 { ChatBubble, SupportPromptGroup } from "../../lib/components"; +import { TestBed } from "../app/test-bed"; +import { ChatBubbleAvatarGenAI, ChatBubbleAvatarUser } from "../chat-bubble/util-components"; +import { ScreenshotArea } from "../screenshot-area"; + +import styles from "./styles.module.scss"; + +export default function SupportPromptPage() { + const [text, setText] = useState(""); + const [text2, setText2] = useState(""); + const [sentText, setSentText] = useState(""); + const ref = createRef(); + + const items = [ + { + text: "Create a really detailed and powerful image. The image should be of a mountain scene with a blue lake and green hills, with a sunset in the background. In the lake, there should be 3 whales leaping out of the water.", + id: "image", + }, + { + text: "Help me brainstorm for an upcoming sign-off.", + id: "brainstorm", + }, + { + text: "Summarize this long and complex PDF for me. Include a paragraph containing 3-4 sentences that capture the main ideas and overall message of the documents, a list of 5 to 10 key points from the document, and up to 3 follow-up questions that arise from the content of the document.", + id: "summarize", + }, + ]; + + const items2 = [ + { + text: "Create a really detailed and powerful image.", + id: "image", + }, + { + text: "Help me brainstorm for an upcoming sign-off.", + id: "brainstorm", + }, + { + text: "Summarize this long PDF for me.", + id: "summarize", + }, + ]; + + return ( + +
+ + + setText("")}>Reset}>Support prompt test: send + } + > + +
+ } ariaLabel="User at 4:23:20pm"> + What can I do with Amazon S3? + + + } type="incoming" ariaLabel="Gen AI at at 4:23:23pm"> + Amazon S3 provides a simple web service interface that you can use to store and retrieve any + amount of data, at any time, from anywhere. + + {text === "" && ( +
+ { + const activeItem = items.find((item) => item.id === detail.id); + setText(activeItem?.text || ""); + console.log(detail); + }} + items={items} + /> +
+ )} +
+ {text !== "" && ( + } ariaLabel="User at 4:23:20pm"> + {text} + + )} +
+ + + + +
+
+ setSentText("")}>Reset}> + Support prompt test: draft + + } + > + +
+ {sentText !== "" && ( + } ariaLabel="User at 4:23:20pm"> + {sentText} + + )} + + {sentText === "" && ( + { + const activeItem = items2.find((item) => item.id === detail.id); + setText2(activeItem?.text || ""); + ref.current?.focus(); + }} + items={items2} + /> + )} + + { + setSentText(text2); + setText2(""); + }} + /> + + + + + +
+
+ ); +} diff --git a/pages/support-prompt-group/simple.page.tsx b/pages/support-prompt-group/simple.page.tsx new file mode 100644 index 0000000..331ebcb --- /dev/null +++ b/pages/support-prompt-group/simple.page.tsx @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SupportPromptGroup } from "../../lib/components"; +import { TestBed } from "../app/test-bed"; +import { ScreenshotArea } from "../screenshot-area"; + +export default function SupportPromptPage() { + return ( + +

Support prompt

+
+ +

horizontal group

+ console.log(detail)} + items={[ + { + text: "Create image", + id: "image", + }, + { + text: "Brainstorm", + id: "brainstorm", + }, + { + text: "Summarize text", + id: "summarize", + }, + ]} + /> + +

vertical group

+ console.log(detail)} + items={[ + { + text: "Create image", + id: "image-2", + }, + { + text: "Brainstorm", + id: "brainstorm-2", + }, + { + text: "Summarize text", + id: "summarize-2", + }, + ]} + /> + +

Horizontal group with really long text

+ console.log(detail)} + items={[ + { + text: "Create a really detailed and powerful image. The image should be of a mountain scene with a blue lake and green hills, with a sunset in the background. In the lake, there should be 3 whales leaping out of the water.", + id: "image", + }, + { + text: "Help me brainstorm for an upcoming sign-off.", + id: "brainstorm", + }, + { + text: "Summarize this long and complex PDF for me. Include a paragraph containing 3-4 sentences that capture the main ideas and overall message of the documents, a list of 5 to 10 key points from the document, and up to 3 follow-up questions that arise from the content of the document.", + id: "summarize", + }, + { + text: "What questions remain unanswered after reading the document(s)? The response shall consider all current or past uploaded documents.", + id: "image-2", + }, + ]} + /> + +

vertical group with really long text

+ console.log(detail)} + items={[ + { + text: "Create a really detailed and powerful image. The image should be of a mountain scene with a blue lake and green hills, with a sunset in the background. In the lake, there should be 3 whales leaping out of the water.", + id: "image", + }, + { + text: "Help me brainstorm for an upcoming sign-off.", + id: "brainstorm", + }, + { + text: "Summarize this long and complex PDF for me. Include a paragraph containing 3-4 sentences that capture the main ideas and overall message of the documents, a list of 5 to 10 key points from the document, and up to 3 follow-up questions that arise from the content of the document.", + id: "summarize", + }, + { + text: "What questions remain unanswered after reading the document(s)? The response shall consider all current or past uploaded documents.", + id: "image-2", + }, + ]} + /> +
+
+
+ ); +} diff --git a/pages/support-prompt-group/styles.module.scss b/pages/support-prompt-group/styles.module.scss new file mode 100644 index 0000000..9297fa6 --- /dev/null +++ b/pages/support-prompt-group/styles.module.scss @@ -0,0 +1,16 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.support-prompt-container { + margin-inline-start: 36px; +} + +.container { + max-width: 1000px; +} + +.placeholder { + block-size: 150px; +} diff --git a/scripts/pluralize.js b/scripts/pluralize.js index 1dc4100..ac9cde9 100644 --- a/scripts/pluralize.js +++ b/scripts/pluralize.js @@ -4,6 +4,7 @@ const pluralizationMap = { Avatar: "Avatars", ChatBubble: "ChatBubbles", LoadingBar: "LoadingBars", + SupportPromptGroup: "SupportPromptGroups", }; function pluralizeComponentName(componentName) { diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 463bc1b..c855894 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -183,3 +183,100 @@ with rounded corners.", "releaseStatus": "stable", } `; + +exports[`definition for support-prompt-group matches the snapshot > support-prompt-group 1`] = ` +{ + "events": [ + { + "cancelable": false, + "description": "Called when the user clicks on a support prompt. The event detail object contains the ID of the clicked item.", + "detailInlineType": { + "name": "SupportPromptGroupProps.ItemClickDetail", + "properties": [ + { + "name": "altKey", + "optional": false, + "type": "boolean", + }, + { + "name": "button", + "optional": false, + "type": "number", + }, + { + "name": "ctrlKey", + "optional": false, + "type": "boolean", + }, + { + "name": "id", + "optional": false, + "type": "string", + }, + { + "name": "metaKey", + "optional": false, + "type": "boolean", + }, + { + "name": "shiftKey", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "SupportPromptGroupProps.ItemClickDetail", + "name": "onItemClick", + }, + ], + "functions": [ + { + "description": "Focuses support prompt group item by ID.", + "name": "focus", + "parameters": [ + { + "name": "itemId", + "type": "string", + }, + ], + "returnType": "void", + }, + ], + "name": "SupportPromptGroup", + "properties": [ + { + "description": "Alignment of the prompts. Defaults to \`vertical\`.", + "inlineType": { + "name": "SupportPromptGroupProps.Alignment", + "type": "union", + "values": [ + "vertical", + "horizontal", + ], + }, + "name": "alignment", + "optional": true, + "type": "string", + }, + { + "description": "Adds an aria label to the support prompt group. +Use this to provide a unique accessible name for each support prompt group on the page.", + "name": "ariaLabel", + "optional": false, + "type": "string", + }, + { + "description": "An array of objects representing support prompts. +Each item has the following properties: + - text: The text of the support prompt. + - id: The ID of the support prompt.", + "name": "items", + "optional": false, + "type": "ReadonlyArray", + }, + ], + "regions": [], + "releaseStatus": "stable", +} +`; diff --git a/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap index dce55d3..58ae847 100644 --- a/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -12,11 +12,13 @@ export { ElementWrapper }; import AvatarWrapper from './avatar'; import ChatBubbleWrapper from './chat-bubble'; import LoadingBarWrapper from './loading-bar'; +import SupportPromptGroupWrapper from './support-prompt-group'; export { AvatarWrapper }; export { ChatBubbleWrapper }; export { LoadingBarWrapper }; +export { SupportPromptGroupWrapper }; declare module '@cloudscape-design/test-utils-core/dist/dom' { interface ElementWrapper { @@ -78,6 +80,25 @@ findLoadingBar(selector?: string): LoadingBarWrapper | null; * @returns {Array} */ findAllLoadingBars(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. + * If no matching SupportPromptGroup is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {SupportPromptGroupWrapper | null} + */ +findSupportPromptGroup(selector?: string): SupportPromptGroupWrapper | null; + +/** + * Returns an array of SupportPromptGroup wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the SupportPromptGroups inside the current wrapper. + * If no matching SupportPromptGroup is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllSupportPromptGroups(selector?: string): Array; } } @@ -112,6 +133,16 @@ ElementWrapper.prototype.findLoadingBar = function(selector) { ElementWrapper.prototype.findAllLoadingBars = function(selector) { return this.findAllComponents(LoadingBarWrapper, selector); }; +ElementWrapper.prototype.findSupportPromptGroup = function(selector) { + const rootSelector = \`.\${SupportPromptGroupWrapper.rootSelector}\`; + // 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, SupportPromptGroupWrapper); +}; + +ElementWrapper.prototype.findAllSupportPromptGroups = function(selector) { + return this.findAllComponents(SupportPromptGroupWrapper, selector); +}; export default function wrapper(root: Element = document.body) { @@ -135,11 +166,13 @@ export { ElementWrapper }; import AvatarWrapper from './avatar'; import ChatBubbleWrapper from './chat-bubble'; import LoadingBarWrapper from './loading-bar'; +import SupportPromptGroupWrapper from './support-prompt-group'; export { AvatarWrapper }; export { ChatBubbleWrapper }; export { LoadingBarWrapper }; +export { SupportPromptGroupWrapper }; declare module '@cloudscape-design/test-utils-core/dist/selectors' { interface ElementWrapper { @@ -195,6 +228,23 @@ findLoadingBar(selector?: string): LoadingBarWrapper; * @returns {MultiElementWrapper} */ findAllLoadingBars(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. + * + * @param {string} [selector] CSS Selector + * @returns {SupportPromptGroupWrapper} + */ +findSupportPromptGroup(selector?: string): SupportPromptGroupWrapper; + +/** + * Returns a multi-element wrapper that matches SupportPromptGroups with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches SupportPromptGroups. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllSupportPromptGroups(selector?: string): MultiElementWrapper; } } @@ -229,6 +279,16 @@ ElementWrapper.prototype.findLoadingBar = function(selector) { ElementWrapper.prototype.findAllLoadingBars = function(selector) { return this.findAllComponents(LoadingBarWrapper, selector); }; +ElementWrapper.prototype.findSupportPromptGroup = function(selector) { + const rootSelector = \`.\${SupportPromptGroupWrapper.rootSelector}\`; + // 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, SupportPromptGroupWrapper); +}; + +ElementWrapper.prototype.findAllSupportPromptGroups = function(selector) { + return this.findAllComponents(SupportPromptGroupWrapper, selector); +}; export default function wrapper(root: string = 'body') { diff --git a/src/__tests__/base-props-support.test.tsx b/src/__tests__/base-props-support.test.tsx index fbdc217..f1d947a 100644 --- a/src/__tests__/base-props-support.test.tsx +++ b/src/__tests__/base-props-support.test.tsx @@ -17,6 +17,7 @@ describe.each(getAllComponents())(`base props support for %s`, async (co test("should allow data-attributes", () => { const { container } = renderComponent(); + expect(container.firstElementChild).toHaveAttribute("data-testid", "example"); }); diff --git a/src/__tests__/default-props.tsx b/src/__tests__/default-props.tsx index f6b664d..7a6b076 100644 --- a/src/__tests__/default-props.tsx +++ b/src/__tests__/default-props.tsx @@ -1,10 +1,17 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import type { AvatarProps } from "../../lib/components"; +import type { SupportPromptGroupProps } from "../../lib/components"; const avatarProps: AvatarProps = { ariaLabel: "Avatar", }; +const supportPromptGroupProps: SupportPromptGroupProps = { + ariaLabel: "Support prompt group", + items: [{ text: "test", id: "test" }], + onItemClick: () => {}, +}; export const defaultProps = { avatar: avatarProps, + ["support-prompt-group"]: supportPromptGroupProps, } as const; diff --git a/src/__tests__/test-utils.test.tsx b/src/__tests__/test-utils.test.tsx index 821a6b4..3de5ba9 100644 --- a/src/__tests__/test-utils.test.tsx +++ b/src/__tests__/test-utils.test.tsx @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + import { ComponentType } from "react"; import { render } from "@testing-library/react"; import { paramCase, pascalCase } from "change-case"; diff --git a/src/index.tsx b/src/index.tsx index a24df82..a4dfd17 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,3 +9,6 @@ export type { LoadingBarProps } from "./loading-bar"; export { default as ChatBubble } from "./chat-bubble"; export type { ChatBubbleProps } from "./chat-bubble"; + +export { default as SupportPromptGroup } from "./support-prompt-group"; +export type { SupportPromptGroupProps } from "./support-prompt-group"; diff --git a/src/internal/events/__tests__/events.test.tsx b/src/internal/events/__tests__/events.test.tsx new file mode 100644 index 0000000..d44603c --- /dev/null +++ b/src/internal/events/__tests__/events.test.tsx @@ -0,0 +1,125 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from "vitest"; + +import { CustomEventStub, fireCancelableEvent } from ".."; + +describe("CustomEventStub", () => { + it("should initialize with default values", () => { + const event = new CustomEventStub(); + + expect(event.defaultPrevented).toBe(false); + expect(event.cancelBubble).toBe(false); + expect(event.cancelable).toBe(false); + expect(event.detail).toBeNull(); + }); + + it("should initialize with provided values", () => { + const detail = { key: "value" }; + const event = new CustomEventStub(true, detail); + + expect(event.cancelable).toBe(true); + expect(event.detail).toBe(detail); + }); + + it("should set defaultPrevented to true when preventDefault is called", () => { + const event = new CustomEventStub(); + event.preventDefault(); + + expect(event.defaultPrevented).toBe(true); + }); + + it("should set cancelBubble to true when stopPropagation is called", () => { + const event = new CustomEventStub(); + event.stopPropagation(); + + expect(event.cancelBubble).toBe(true); + }); + + it("should work with generic detail type", () => { + const event = new CustomEventStub(false, "Test detail"); + + expect(event.detail).toBe("Test detail"); + expect(typeof event.detail).toBe("string"); + }); +}); + +describe("fireCancelableEvent", () => { + it("should call the handler with a cancelable event and return the result", () => { + const handler = vi.fn(); + const detail = { key: "value" }; + + const result = fireCancelableEvent(handler, detail); + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event.cancelable).toBe(true); + expect(event.detail).toEqual(detail); + expect(result).toBe(false); + }); + + it("should prevent the default action if preventDefault is called in the handler", () => { + const handler = vi.fn((event) => { + event.preventDefault(); + }); + const detail = { key: "value" }; + + const result = fireCancelableEvent(handler, detail); + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event.defaultPrevented).toBe(true); + + expect(result).toBe(true); + }); + + it("should call sourceEvent.preventDefault() when event.defaultPrevented is true", () => { + const handler = vi.fn((event) => { + event.preventDefault(); + }); + const detail = { key: "value" }; + const mockSourceEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as any; + + const result = fireCancelableEvent(handler, detail, mockSourceEvent); + + expect(handler).toHaveBeenCalled(); + expect(mockSourceEvent.preventDefault).toHaveBeenCalled(); + expect(result).toBe(true); // Ensures the event's default was prevented + }); + + it("should stop propagation if stopPropagation is called in the handler", () => { + const handler = vi.fn((event) => { + event.stopPropagation(); + }); + const detail = { key: "value" }; + const mockSourceEvent = { stopPropagation: vi.fn() }; + + const result = fireCancelableEvent(handler, detail, mockSourceEvent as unknown as Event); + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event.cancelBubble).toBe(true); + expect(mockSourceEvent.stopPropagation).toHaveBeenCalledTimes(1); + expect(result).toBe(false); + }); + + it("should not call preventDefault or stopPropagation on sourceEvent if the event handler does not trigger them", () => { + const handler = vi.fn(); + const detail = { key: "value" }; + const mockSourceEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + fireCancelableEvent(handler, detail, mockSourceEvent as unknown as Event); + + expect(mockSourceEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockSourceEvent.stopPropagation).not.toHaveBeenCalled(); + }); + + it("should return false if no handler is provided", () => { + const result = fireCancelableEvent(undefined, { key: "value" }); + + expect(result).toBe(false); + }); +}); diff --git a/src/internal/events/index.ts b/src/internal/events/index.ts new file mode 100644 index 0000000..7ec6f4d --- /dev/null +++ b/src/internal/events/index.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export type CancelableEventHandler = (event: CustomEvent) => void; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type NonCancelableEventHandler = (event: NonCancelableCustomEvent) => void; + +export type NonCancelableCustomEvent = Omit, "preventDefault">; + +export interface ClickDetail { + button: number; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +} + +export class CustomEventStub { + defaultPrevented = false; + cancelBubble = false; + constructor( + public cancelable: boolean = false, + public detail: T | null = null, + ) {} + + preventDefault() { + this.defaultPrevented = true; + } + + stopPropagation() { + this.cancelBubble = true; + } +} + +export function createCustomEvent({ cancelable, detail }: CustomEventInit): CustomEvent { + return new CustomEventStub(cancelable, detail) as CustomEvent; +} + +export function fireCancelableEvent( + handler: CancelableEventHandler | undefined, + detail: T, + sourceEvent?: React.SyntheticEvent | Event, +) { + if (!handler) { + return false; + } + const event = createCustomEvent({ cancelable: true, detail }); + handler(event); + if (event.defaultPrevented && sourceEvent) { + sourceEvent.preventDefault(); + } + if (event.cancelBubble && sourceEvent) { + sourceEvent.stopPropagation(); + } + return event.defaultPrevented; +} + +export function fireNonCancelableEvent(handler: NonCancelableEventHandler | undefined, detail?: T) { + if (!handler) { + return; + } + const event = createCustomEvent({ cancelable: false, detail }); + handler(event); +} diff --git a/src/internal/utils/use-forward-focus.ts b/src/internal/utils/use-forward-focus.ts new file mode 100644 index 0000000..dd44679 --- /dev/null +++ b/src/internal/utils/use-forward-focus.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useImperativeHandle } from "react"; + +export interface ForwardFocusRef { + focus(): void; +} + +export default function useForwardFocus( + mainRef: React.Ref, + controlRef: React.RefObject<{ focus: HTMLElement["focus"] }>, +) { + useImperativeHandle( + mainRef, + () => ({ + focus(...args: Parameters) { + controlRef.current?.focus(...args); + }, + }), + [controlRef], + ); +} diff --git a/src/support-prompt-group/__tests__/focus-helpers.test.tsx b/src/support-prompt-group/__tests__/focus-helpers.test.tsx new file mode 100644 index 0000000..725a0ee --- /dev/null +++ b/src/support-prompt-group/__tests__/focus-helpers.test.tsx @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, test, vi } from "vitest"; + +import { getNextFocusTarget, onUnregisterActive } from "../../../lib/components/support-prompt-group/focus-helpers"; + +describe("getNextFocusTarget", () => { + test("should return null if containerObjectRef.current is null", () => { + const containerObjectRef = { current: null }; // Simulate null container + const focusedIdRef = { current: null }; // Simulate no focused item + + const result = getNextFocusTarget(containerObjectRef, focusedIdRef); + + expect(result).toBeNull(); + }); +}); + +describe("onUnregisterActive", () => { + test("should call target.focus() when target exists and has a different dataset.itemid", () => { + const mockFocus = vi.fn(); + const mockTarget = document.createElement("button"); + mockTarget.dataset.itemid = "different-id"; + mockTarget.focus = mockFocus; + + const mockNavigationAPI = { + current: { + getFocusTarget: vi.fn(() => mockTarget), + }, + }; + + const focusableElement = document.createElement("div"); + focusableElement.dataset.itemid = "current-id"; + + onUnregisterActive(focusableElement, mockNavigationAPI); + + expect(mockNavigationAPI.current?.getFocusTarget).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalledTimes(1); + }); + + test("should not call target.focus() when target.dataset.itemid matches focusableElement.dataset.itemid", () => { + const mockFocus = vi.fn(); + const mockTarget = document.createElement("button"); + mockTarget.dataset.itemid = "same-id"; + mockTarget.focus = mockFocus; + + const mockNavigationAPI = { + current: { + getFocusTarget: vi.fn(() => mockTarget), + }, + }; + + const focusableElement = document.createElement("div"); + focusableElement.dataset.itemid = "same-id"; + + onUnregisterActive(focusableElement, mockNavigationAPI); + + expect(mockNavigationAPI.current?.getFocusTarget).toHaveBeenCalled(); + expect(mockFocus).not.toHaveBeenCalled(); + }); + + test("should not call target.focus() when getFocusTarget returns null", () => { + const mockNavigationAPI = { + current: { + getFocusTarget: vi.fn(() => null), + }, + }; + + const focusableElement = document.createElement("div"); + focusableElement.dataset.itemid = "current-id"; + + onUnregisterActive(focusableElement, mockNavigationAPI); + + expect(mockNavigationAPI.current?.getFocusTarget).toHaveBeenCalled(); + }); +}); diff --git a/src/support-prompt-group/__tests__/support-prompt-group.test.tsx b/src/support-prompt-group/__tests__/support-prompt-group.test.tsx new file mode 100644 index 0000000..dd3a602 --- /dev/null +++ b/src/support-prompt-group/__tests__/support-prompt-group.test.tsx @@ -0,0 +1,179 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import * as ComponentToolkitInternal from "@cloudscape-design/component-toolkit/internal"; + +import SupportPromptGroup, { SupportPromptGroupProps } from "../../../lib/components/support-prompt-group"; +import createWrapper from "../../../lib/components/test-utils/dom"; + +import styles from "../../../lib/components/support-prompt-group/styles.selectors.js"; + +const onItemClick = vi.fn(); + +const defaultProps = { + onItemClick, + ariaLabel: "Test support prompt group", + items: [ + { + text: "Item 1", + id: "item-1", + }, + { + text: "Item 2", + id: "item-2", + }, + { + text: "Item 3", + id: "item-3", + }, + ], +}; + +export function renderSupportPromptGroup( + props: Partial, + ref?: React.Ref, +) { + const renderResult = render(); + const wrapper = createWrapper(renderResult.container).findSupportPromptGroup()!; + + return wrapper; +} + +describe("Support prompt group", () => { + afterEach(() => { + cleanup(); + }); + + test("Renders null with no items", () => { + const wrapper = renderSupportPromptGroup({ + items: [], + }); + + expect(wrapper).toBeNull(); + }); + + test("Finds number of items", () => { + const wrapper = renderSupportPromptGroup({}); + + expect(wrapper.findItems().length).toBe(3); + }); + + test("Finds item by id", () => { + const wrapper = renderSupportPromptGroup({}); + + expect(wrapper.findItemById("item-1")!.getElement()).toHaveTextContent("Item 1"); + }); + + test("fires onClick", () => { + const wrapper = renderSupportPromptGroup({}); + + wrapper.findItemById("item-1")!.click(); + + expect(onItemClick).toHaveBeenCalledTimes(1); + }); + + test("Assigns vertical class with vertical alignment", () => { + const wrapper = renderSupportPromptGroup({ + alignment: "vertical", + }); + + expect(wrapper.getElement()).toHaveClass(styles.vertical); + }); + + describe("a11y", () => { + test("Group has accessible name", () => { + const wrapper = renderSupportPromptGroup({}); + + expect(wrapper.getElement()).toHaveAccessibleName("Test support prompt group"); + }); + + test("Prompt has accessible name", () => { + const wrapper = renderSupportPromptGroup({}); + + expect(wrapper.findItemById("item-1")!.getElement()).toHaveAccessibleName("Item 1"); + }); + }); + + describe("focus", () => { + const ref: { current: SupportPromptGroupProps.Ref | null } = { current: null }; + + test("Focuses on element using ref", () => { + const wrapper = renderSupportPromptGroup({}, ref); + + ref.current!.focus("item-1"); + + expect(wrapper.findItemById("item-1")!.getElement()).toHaveFocus(); + }); + + test("Throws console warning when no ID is found", () => { + renderSupportPromptGroup({}, ref); + const warnOnce = vi.spyOn(ComponentToolkitInternal, "warnOnce"); + + ref.current!.focus("doesnt-exist"); + + expect(document.body).toHaveFocus(); + + expect(warnOnce).toHaveBeenCalledTimes(1); + expect(warnOnce).toHaveBeenCalledWith("SupportPromptGroup", `No matching ID found to focus.`); + }); + }); + describe("Keyboard navigation", () => { + const ref: { current: SupportPromptGroupProps.Ref | null } = { current: null }; + const { KeyCode } = ComponentToolkitInternal; + + test("Arrow keys move focus in vertical alignment", () => { + const wrapper = renderSupportPromptGroup({}, ref); + + ref.current!.focus("item-1"); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + expect(wrapper.findItemById("item-2")!.getElement()).toHaveFocus(); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.down }); + expect(wrapper.findItemById("item-3")!.getElement()).toHaveFocus(); + }); + + test("Arrow keys move focus in horizontal alignment", () => { + const wrapper = renderSupportPromptGroup({ alignment: "horizontal" }, ref); + + ref.current!.focus("item-1"); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.down }); + expect(wrapper.findItemById("item-2")!.getElement()).toHaveFocus(); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + expect(wrapper.findItemById("item-3")!.getElement()).toHaveFocus(); + }); + + test("Focus loops", () => { + const wrapper = renderSupportPromptGroup({}, ref); + + ref.current!.focus("item-1"); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + expect(wrapper.findItemById("item-2")!.getElement()).toHaveFocus(); + }); + + test("Modifier keys don't move focus", () => { + const wrapper = renderSupportPromptGroup({}, ref); + + ref.current!.focus("item-1"); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.down, ctrlKey: true }); + expect(wrapper.findItemById("item-1")!.getElement()).toHaveFocus(); + }); + + test("Nonexistent target doesn't move focus", () => { + const wrapper = renderSupportPromptGroup({}, ref); + ref.current!.focus("doesnt-exist"); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.down }); + expect(document.body).toHaveFocus(); + }); + }); +}); diff --git a/src/support-prompt-group/focus-helpers.tsx b/src/support-prompt-group/focus-helpers.tsx new file mode 100644 index 0000000..af76e6f --- /dev/null +++ b/src/support-prompt-group/focus-helpers.tsx @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import styles from "./styles.css.js"; + +export function getNextFocusTarget( + containerObjectRef: React.RefObject, + focusedIdRef: React.MutableRefObject, +): null | HTMLElement { + if (containerObjectRef.current) { + const buttons: HTMLButtonElement[] = Array.from( + containerObjectRef?.current.querySelectorAll(`.${styles["support-prompt"]}`), + ); + + if (focusedIdRef.current) { + const buttonWithId = buttons.find((button) => button.dataset.itemid === focusedIdRef.current); + if (buttonWithId) { + return buttonWithId; + } + } + + return buttons[0] ?? null; + } + return null; +} + +export function onUnregisterActive( + focusableElement: HTMLElement, + navigationAPI: React.RefObject<{ getFocusTarget: () => HTMLElement | null }>, +) { + const target = navigationAPI.current?.getFocusTarget(); + + if (target && target.dataset.itemid !== focusableElement.dataset.itemid) { + target.focus(); + } +} diff --git a/src/support-prompt-group/index.tsx b/src/support-prompt-group/index.tsx new file mode 100644 index 0000000..a4102eb --- /dev/null +++ b/src/support-prompt-group/index.tsx @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { forwardRef, Ref } from "react"; + +import useBaseComponent from "../internal/base-component/use-base-component"; +import { applyDisplayName } from "../internal/utils/apply-display-name"; +import { SupportPromptGroupProps } from "./interfaces"; +import { InternalSupportPromptGroup } from "./internal"; + +export type { SupportPromptGroupProps }; + +const SupportPromptGroup = forwardRef((props: SupportPromptGroupProps, ref: Ref) => { + const baseComponentProps = useBaseComponent("SupportPromptGroup"); + return ; +}); + +applyDisplayName(SupportPromptGroup, "SupportPromptGroup"); +export default SupportPromptGroup; diff --git a/src/support-prompt-group/interfaces.ts b/src/support-prompt-group/interfaces.ts new file mode 100644 index 0000000..61e437a --- /dev/null +++ b/src/support-prompt-group/interfaces.ts @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ClickDetail as _ClickDetail, NonCancelableEventHandler } from "../internal/events"; + +export interface SupportPromptGroupProps { + /** + * Alignment of the prompts. Defaults to `vertical`. + **/ + alignment?: SupportPromptGroupProps.Alignment; + + /** + * An array of objects representing support prompts. + * Each item has the following properties: + * - text: The text of the support prompt. + * - id: The ID of the support prompt. + **/ + items: ReadonlyArray; + + /** + * Called when the user clicks on a support prompt. The event detail object contains the ID of the clicked item. + */ + onItemClick: NonCancelableEventHandler; + + /** + * Adds an aria label to the support prompt group. + * Use this to provide a unique accessible name for each support prompt group on the page. + */ + ariaLabel: string; +} + +export namespace SupportPromptGroupProps { + export type Alignment = "vertical" | "horizontal"; + + export interface Item { + text: string; + id: string; + } + + export interface ItemClickDetail extends _ClickDetail { + id: string; + } + + export interface Ref { + /** + * Focuses support prompt group item by ID. + */ + focus(itemId: string): void; + } +} diff --git a/src/support-prompt-group/internal.tsx b/src/support-prompt-group/internal.tsx new file mode 100644 index 0000000..101d236 --- /dev/null +++ b/src/support-prompt-group/internal.tsx @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { forwardRef, Ref, useEffect, useImperativeHandle, useRef } from "react"; +import clsx from "clsx"; + +import { + circleIndex, + getAllFocusables, + handleKey, + KeyCode, + SingleTabStopNavigationAPI, + SingleTabStopNavigationProvider, + warnOnce, +} from "@cloudscape-design/component-toolkit/internal"; + +import { getDataAttributes } from "../internal/base-component/get-data-attributes"; +import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; +import { fireNonCancelableEvent } from "../internal/events"; +import { useMergeRefs } from "../internal/utils/use-merge-refs"; +import { getNextFocusTarget, onUnregisterActive } from "./focus-helpers"; +import { SupportPromptGroupProps } from "./interfaces"; +import { Prompt } from "./prompt"; + +import styles from "./styles.css.js"; + +export const InternalSupportPromptGroup = forwardRef( + ( + { + alignment = "vertical", + onItemClick, + items, + __internalRootRef, + ariaLabel, + ...rest + }: SupportPromptGroupProps & InternalBaseComponentProps, + ref: Ref, + ) => { + const focusedIdRef = useRef(null); + const navigationAPI = useRef(null); + const containerObjectRef = useRef(null); + const itemsRef = useRef>({}); + + const mergedRef = useMergeRefs(containerObjectRef, __internalRootRef); + + useImperativeHandle(ref, () => ({ + focus: (id) => { + if (!itemsRef.current[id]) { + warnOnce("SupportPromptGroup", "No matching ID found to focus."); + } + itemsRef.current[id]?.focus(); + }, + })); + + const handleClick = (event: React.MouseEvent, id: string) => { + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + + fireNonCancelableEvent(onItemClick, { id, altKey, button, ctrlKey, metaKey, shiftKey }); + }; + + useEffect(() => { + navigationAPI.current?.updateFocusTarget(); + }); + + function onFocus(event: React.FocusEvent) { + if (event.target instanceof HTMLElement && event.target.dataset.itemid) { + focusedIdRef.current = event.target.dataset.itemid; + } + + navigationAPI.current?.updateFocusTarget(); + } + + function onBlur() { + navigationAPI.current?.updateFocusTarget(); + } + + function onKeyDown(event: React.KeyboardEvent) { + const focusTarget = navigationAPI.current?.getFocusTarget(); + const specialKeys = [ + KeyCode.right, + KeyCode.left, + KeyCode.up, + KeyCode.down, + KeyCode.end, + KeyCode.home, + KeyCode.pageUp, + KeyCode.pageDown, + ]; + + const hasModifierKeys = (event: React.MouseEvent | React.KeyboardEvent) => { + return event.ctrlKey || event.altKey || event.shiftKey || event.metaKey; + }; + + if (hasModifierKeys(event) || !specialKeys.includes(event.keyCode)) { + return; + } + if (!containerObjectRef.current || !focusTarget) { + return; + } + // Ignore navigation when the focused element is not an item. + if (document.activeElement && !document.activeElement.matches(`.${styles["support-prompt"]}`)) { + return; + } + event.preventDefault(); + + const focusables = getFocusablesFrom(containerObjectRef.current); + const activeIndex = focusables.indexOf(focusTarget); + handleKey(event as any, { + onHome: () => focusElement(focusables[0]), + onEnd: () => focusElement(focusables[focusables.length - 1]), + onInlineStart: () => focusElement(focusables[circleIndex(activeIndex - 1, [0, focusables.length - 1])]), + onInlineEnd: () => focusElement(focusables[circleIndex(activeIndex + 1, [0, focusables.length - 1])]), + onBlockStart: () => focusElement(focusables[circleIndex(activeIndex - 1, [0, focusables.length - 1])]), + onBlockEnd: () => focusElement(focusables[circleIndex(activeIndex + 1, [0, focusables.length - 1])]), + }); + } + + function focusElement(element: HTMLElement) { + element.focus(); + } + + // List all non-disabled and registered focusables: those are eligible for keyboard navigation. + function getFocusablesFrom(target: HTMLElement) { + function isElementRegistered(element: HTMLElement) { + return navigationAPI.current?.isRegistered(element) ?? false; + } + + return getAllFocusables(target).filter((el) => isElementRegistered(el)); + } + + if (!items || items.length === 0) { + return
; + } + + return ( +
+ getNextFocusTarget(containerObjectRef, focusedIdRef)} + onUnregisterActive={(element: HTMLElement) => onUnregisterActive(element, navigationAPI)} + > + {items.map((item, index) => { + return ( + handleClick(event, item.id)} + id={item.id} + ref={(element) => (itemsRef.current[item.id] = element)} + > + {item.text} + + ); + })} + +
+ ); + }, +); diff --git a/src/support-prompt-group/prompt.tsx b/src/support-prompt-group/prompt.tsx new file mode 100644 index 0000000..693ef78 --- /dev/null +++ b/src/support-prompt-group/prompt.tsx @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { forwardRef, Ref, useRef } from "react"; +import clsx from "clsx"; + +import { useSingleTabStopNavigation } from "@cloudscape-design/component-toolkit/internal"; + +import useForwardFocus from "../internal/utils/use-forward-focus"; + +import styles from "./styles.css.js"; + +export interface PromptProps { + children: string; + id: string; + onClick: (event: React.MouseEvent, id: string) => void; +} + +export const Prompt = forwardRef(({ children, id, onClick }: PromptProps, ref: Ref) => { + const buttonRef = useRef(null); + useForwardFocus(ref, buttonRef); + + const { tabIndex } = useSingleTabStopNavigation(buttonRef); + + return ( + + ); +}); diff --git a/src/support-prompt-group/styles.scss b/src/support-prompt-group/styles.scss new file mode 100644 index 0000000..5df08c0 --- /dev/null +++ b/src/support-prompt-group/styles.scss @@ -0,0 +1,61 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use "../../node_modules/@cloudscape-design/design-tokens/index.scss" as awsui; +@use "../internal/shared" as shared; + +.root { + @include shared.styles-reset; + + display: flex; + flex-wrap: wrap; + gap: awsui.$space-scaled-xs; + + &.vertical { + flex-direction: column; + } +} + +.support-prompt { + cursor: pointer; + + font-family: awsui.$font-family-base; + font-size: awsui.$font-size-body-m; + line-height: awsui.$line-height-heading-m; + text-align: start; + + padding-block: awsui.$space-scaled-xs; + padding-inline: awsui.$space-static-s; + + color: awsui.$color-text-button-normal-default; + background: awsui.$color-background-button-normal-default; + + border: 1px solid awsui.$color-border-button-normal-default; + border-start-start-radius: awsui.$space-static-xs; + border-start-end-radius: awsui.$space-static-xs; + border-end-start-radius: awsui.$space-static-xs; + border-end-end-radius: awsui.$space-static-xs; + + inline-size: fit-content; + + &:hover { + color: awsui.$color-text-button-normal-hover; + background: awsui.$color-background-button-normal-hover; + border-color: awsui.$color-border-button-normal-hover; + } + + &:active { + color: awsui.$color-text-button-normal-active; + background: awsui.$color-background-button-normal-active; + border-color: awsui.$color-border-button-normal-active; + } + + &:focus { + outline: none; + + @include shared.when-visible { + @include shared.focus-highlight(6px, 6px); + } + } +} diff --git a/src/test-utils/dom/support-prompt-group/index.ts b/src/test-utils/dom/support-prompt-group/index.ts new file mode 100644 index 0000000..a0cdfa9 --- /dev/null +++ b/src/test-utils/dom/support-prompt-group/index.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper, ElementWrapper } from "@cloudscape-design/test-utils-core/dom"; + +import styles from "../../../support-prompt-group/styles.selectors.js"; + +export default class SupportPromptGroupWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; + + /** + * Finds all items. + */ + findItems(): Array { + return this.findAllByClassName(styles["support-prompt"]); + } + + /** + * Finds a support prompt item by its id. + */ + findItemById(id: string): null | SupportPromptWrapper { + const itemSelector = `.${styles["support-prompt"]}[data-testid="${CSS.escape(id)}"]`; + const wrapper = this.find(itemSelector) as ElementWrapper; + return wrapper && new SupportPromptWrapper(wrapper.getElement()); + } +} + +export class SupportPromptWrapper extends ComponentWrapper { + static rootSelector: string = styles["support-prompt"]; +}