diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index 6eaa4e77c..74f41e93e 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -239,6 +239,46 @@ describe("image-upload", () => { ); }); + describe("tooltip", () => { + const onTooltipClick = jest.fn(); + + it("should not render tooltip when tooltip prop is not provided", async () => { + await renderComponent(); + + expect(screen.queryByTestId("field__tooltip")).not.toBeInTheDocument(); + }); + + it("should render tooltip when tooltip prop is provided", async () => { + await renderComponent({ overrideField: { tooltip: {} } }); + + expect(screen.getByTestId("field__tooltip")).toBeInTheDocument(); + }); + + it("should fire click-tooltip event when tooltip is clicked", async () => { + await renderComponent({ + overrideField: { tooltip: {} }, + eventType: "click-tooltip", + eventListener: onTooltipClick, + }); + + fireEvent.click(screen.getByTestId("field__tooltip")); + + expect(onTooltipClick).toHaveBeenCalledTimes(1); + }); + + it("should render label text when label is provided", async () => { + await renderComponent({ overrideField: { tooltip: { label: "More info" } } }); + + expect(screen.getByText("More info")).toBeInTheDocument(); + }); + + it("should render icon when icon is provided", async () => { + await renderComponent({ overrideField: { tooltip: { icon: "ICircleFillIcon" } } }); + + expect(screen.getByTestId("field__tooltip").querySelector("svg")).toBeInTheDocument(); + }); + }); + describe("validation", () => { it("should support validation schema", async () => { await renderComponent({ @@ -327,9 +367,7 @@ describe("image-upload", () => { await waitFor(() => fireEvent.click(getSubmitButton())); expect(SUBMIT_FN).toHaveBeenCalledWith( expect.objectContaining({ - field: expect.arrayContaining([ - expect.objectContaining({ fileName: FILE_1.name }), - ]), + field: expect.arrayContaining([expect.objectContaining({ fileName: FILE_1.name })]), }) ); }); diff --git a/src/components/fields/image-upload/image-input/image-input.styles.ts b/src/components/fields/image-upload/image-input/image-input.styles.ts index 3769f8fd1..3119865c0 100644 --- a/src/components/fields/image-upload/image-input/image-input.styles.ts +++ b/src/components/fields/image-upload/image-input/image-input.styles.ts @@ -24,6 +24,38 @@ export const Subtitle = styled(Typography.BodyBL)` margin-bottom: ${(props) => (props.$hasDescription ? Spacing["spacing-8"] : Spacing["spacing-16"])}; `; +export const TooltipWrapper = styled.button` + display: inline-flex; + align-items: center; + gap: ${Spacing["spacing-4"]}; + margin-top: ${Spacing["spacing-8"]}; + margin-bottom: ${Spacing["spacing-16"]}; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + color: ${Colour["text-primary"]}; + + &:hover { + color: ${Colour["text-hover"]}; + } +`; + +export const TooltipIcon = styled.span` + width: 1rem; + height: 1rem; + color: inherit; + + svg { + width: 100%; + height: 100%; + } +`; + +export const TooltipLabel = styled(Typography.BodyMD)` + color: inherit; +`; + export const Content = styled.div` ${Font["body-md-regular"]}; margin-bottom: ${Spacing["spacing-24"]}; diff --git a/src/components/fields/image-upload/image-input/image-input.tsx b/src/components/fields/image-upload/image-input/image-input.tsx index 9fe2c2e4a..48730d03c 100644 --- a/src/components/fields/image-upload/image-input/image-input.tsx +++ b/src/components/fields/image-upload/image-input/image-input.tsx @@ -1,4 +1,5 @@ -import React, { createRef, useContext, useEffect, useState } from "react"; +import { createRef, useContext, useEffect, useState } from "react"; +import * as Icons from "@lifesg/react-icons"; import { TestHelper, generateRandomId } from "../../../../utils"; import { useFieldEvent, usePrevious } from "../../../../utils/hooks"; import { ERROR_MESSAGES, Sanitize } from "../../../shared"; @@ -13,6 +14,9 @@ import { Content, DropThemHereText, Subtitle, + TooltipIcon, + TooltipLabel, + TooltipWrapper, UploadWrapper, Wrapper, } from "./image-input.styles"; @@ -28,6 +32,7 @@ interface IImageInputProps extends ISharedImageProps { validation: IImageUploadValidationRule[]; multiple?: boolean | undefined; warning?: string | undefined; + tooltip?: { label?: string | undefined; icon?: keyof typeof Icons | undefined } | undefined; } /** @@ -52,6 +57,7 @@ export const ImageInput = (props: IImageInputProps) => { errorMessage, multiple, warning, + tooltip, } = props; const { images, setImages, setErrorCount } = useContext(ImageContext); const { dispatchFieldEvent } = useFieldEvent(); @@ -121,6 +127,15 @@ export const ImageInput = (props: IImageInputProps) => { // ============================================================================= // RENDER FUNCTIONS // ============================================================================= + const renderTooltipIcon = (icon: keyof typeof Icons) => { + const Icon = Icons[icon]; + return ( + + + + ); + }; + const renderFiles = () => { if (!images || !images.length) return null; return images.map((fileItem: IImage, i: number) => { @@ -217,6 +232,16 @@ export const ImageInput = (props: IImageInputProps) => { > {label} + {tooltip && ( + dispatchFieldEvent("click-tooltip", id)} + > + {tooltip.label && {tooltip.label}} + {tooltip.icon && renderTooltipIcon(tooltip.icon)} + + )} {description && ( {description} diff --git a/src/components/fields/image-upload/image-upload.tsx b/src/components/fields/image-upload/image-upload.tsx index 4795b7350..c0d401d10 100644 --- a/src/components/fields/image-upload/image-upload.tsx +++ b/src/components/fields/image-upload/image-upload.tsx @@ -40,6 +40,7 @@ export const ImageUploadInner = (props: IGenericFieldProps) validation, multiple, imageReviewModalStyles, + tooltip, }, id, isDirty, @@ -291,6 +292,7 @@ export const ImageUploadInner = (props: IGenericFieldProps) validation={validation} multiple={multiple} warning={warning} + tooltip={tooltip} /> {renderReviewPrompt()} diff --git a/src/components/fields/image-upload/types.ts b/src/components/fields/image-upload/types.ts index 34ea4a458..46a6b5a2e 100644 --- a/src/components/fields/image-upload/types.ts +++ b/src/components/fields/image-upload/types.ts @@ -1,3 +1,4 @@ +import * as Icons from "@lifesg/react-icons"; import { FabricObject } from "fabric"; import { IBaseFieldSchema } from "../types"; import { IYupValidationRule } from "../../../context-providers"; @@ -33,6 +34,7 @@ export interface IImageUploadSchema capture?: TFileCapture | undefined; multiple?: boolean | undefined; imageReviewModalStyles?: string | undefined; + tooltip?: { label?: string | undefined; icon?: keyof typeof Icons | undefined } | undefined; } export interface ISharedImageProps { @@ -182,6 +184,14 @@ function imageUploadEvent( listener: TFieldEventListener<{ imageData: IImage }>, options?: boolean | AddEventListenerOptions | undefined ): void; +/** fired when the tooltip is clicked */ +function imageUploadEvent( + uiType: "image-upload", + type: "click-tooltip", + id: string, + listener: TFieldEventListener, + options?: boolean | AddEventListenerOptions | undefined +): void; function imageUploadEvent() { // } diff --git a/src/stories/3-fields/image-upload/image-upload.stories.tsx b/src/stories/3-fields/image-upload/image-upload.stories.tsx index acf994e3d..3ba2ad1ba 100644 --- a/src/stories/3-fields/image-upload/image-upload.stories.tsx +++ b/src/stories/3-fields/image-upload/image-upload.stories.tsx @@ -1,12 +1,17 @@ +import { action } from "@storybook/addon-actions"; import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; -import { Meta } from "@storybook/react"; +import { Meta, StoryFn } from "@storybook/react"; +import { useEffect, useRef } from "react"; import { IImageUploadSchema } from "../../../components/fields"; +import { IFrontendEngineRef } from "../../../components/frontend-engine"; import { CommonFieldStoryProps, DefaultStoryTemplate, + FrontendEngine, OVERRIDES_ARG_TYPE, OverrideStoryTemplate, ResetStoryTemplate, + SUBMIT_BUTTON_SCHEMA, WarningStoryTemplate, } from "../../common"; import { jpgDataURL } from "./image-data-url"; @@ -174,6 +179,16 @@ const meta: Meta = { type: "boolean", }, }, + tooltip: { + description: + "A clickable element rendered between the label and description. `label` sets the button text. `icon` must be a valid icon name from `@lifesg/react-icons` (e.g. `ICircleFillIcon`, `QuestionmarkCircleIcon`). `onClick` is the callback triggered when clicked.", + table: { + type: { + summary: "{ label?: string; icon?: keyof typeof Icons }", + }, + defaultValue: { summary: null }, + }, + }, }, }; export default meta; @@ -376,3 +391,37 @@ Multiple.args = { multiple: true, editImage: true, }; + +export const WithTooltip: StoryFn = (args: IImageUploadSchema) => { + const id = "upload-with-tooltip"; + const formRef = useRef(); + const handleTooltipClick = (e: unknown) => action("click-tooltip")(e); + useEffect(() => { + const currentFormRef = formRef.current; + currentFormRef.addFieldEventListener("image-upload", "click-tooltip", id, handleTooltipClick); + return () => currentFormRef.removeFieldEventListener("image-upload", "click-tooltip", id, handleTooltipClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + + ); +}; +WithTooltip.args = { + label: "Provide images", + uiType: "image-upload", + description: "Click the tooltip element between the label and description", + tooltip: { label: "More info", icon: "ICircleFillIcon" } as any, +};