From 0f1d0c45cabc4c20f88bbe4e4cfc1ffb808717d3 Mon Sep 17 00:00:00 2001 From: Matthew Auw Date: Fri, 10 Apr 2026 11:21:26 +0800 Subject: [PATCH 1/5] [MOL-18749][MA] add tooltip to image-upload component --- .../image-input/image-input.styles.ts | 34 +++++++++++++++++++ .../image-upload/image-input/image-input.tsx | 27 ++++++++++++++- .../fields/image-upload/image-upload.tsx | 2 ++ src/components/fields/image-upload/types.ts | 2 ++ 4 files changed, 64 insertions(+), 1 deletion(-) 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..e3e66a5a1 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,40 @@ 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; + + &:hover { + opacity: 0.8; + } +`; + +export const TooltipIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + color: ${Colour["text-primary"]}; + + svg { + width: 100%; + height: 100%; + } +`; + +export const TooltipLabel = styled(Typography.BodyMD)` + color: ${Colour["text-primary"]}; +`; + 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..b10f26933 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; onClick: () => void } | 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 && ( + + {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..8e3ebc4fb 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; onClick: () => void } | undefined; } export interface ISharedImageProps { From ea0581eae906723ed0561b464d052990b6729d2b Mon Sep 17 00:00:00 2001 From: Matthew Auw Date: Fri, 10 Apr 2026 11:23:12 +0800 Subject: [PATCH 2/5] [MOL-18749][MA] add WithTooltip story to image-upload --- .../image-upload/image-upload.stories.tsx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) 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..972d0dc9f 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,15 @@ +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 { IImageUploadSchema } from "../../../components/fields"; import { CommonFieldStoryProps, DefaultStoryTemplate, + FrontendEngine, OVERRIDES_ARG_TYPE, OverrideStoryTemplate, ResetStoryTemplate, + SUBMIT_BUTTON_SCHEMA, WarningStoryTemplate, } from "../../common"; import { jpgDataURL } from "./image-data-url"; @@ -174,6 +177,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; onClick: () => void }", + }, + defaultValue: { summary: null }, + }, + }, }, }; export default meta; @@ -376,3 +389,30 @@ Multiple.args = { multiple: true, editImage: true, }; + +export const WithTooltip: StoryFn = (args: IImageUploadSchema) => ( + +); +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, +}; From 647dfab0a2060d7133f069095a69c68dfa02a23f Mon Sep 17 00:00:00 2001 From: Matthew Auw Date: Fri, 10 Apr 2026 12:10:24 +0800 Subject: [PATCH 3/5] [MOL-18749][MA] update image-upload test --- .../fields/image-upload/image-upload.spec.tsx | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) 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..6f2e33f23 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,42 @@ 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: { onClick: onTooltipClick } } }); + + expect(screen.getByTestId("field__tooltip")).toBeInTheDocument(); + }); + + it("should call onClick when tooltip is clicked", async () => { + await renderComponent({ overrideField: { tooltip: { onClick: 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", onClick: onTooltipClick } } }); + + expect(screen.getByText("More info")).toBeInTheDocument(); + }); + + it("should render icon when icon is provided", async () => { + await renderComponent({ overrideField: { tooltip: { icon: "ICircleFillIcon", onClick: onTooltipClick } } }); + + expect(screen.getByTestId("field__tooltip").querySelector("svg")).toBeInTheDocument(); + }); + }); + describe("validation", () => { it("should support validation schema", async () => { await renderComponent({ @@ -327,9 +363,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 })]), }) ); }); From 944fc7c58381ae92a745125b2d4fbfa03ec3904d Mon Sep 17 00:00:00 2001 From: Matthew Auw Date: Mon, 13 Apr 2026 10:00:32 +0800 Subject: [PATCH 4/5] [MOL-18749][MA] update tooltip to dispatchFieldEvent --- .../fields/image-upload/image-upload.spec.tsx | 14 +++--- .../image-input/image-input.styles.ts | 7 +-- .../image-upload/image-input/image-input.tsx | 4 +- src/components/fields/image-upload/types.ts | 10 ++++- .../image-upload/image-upload.stories.tsx | 45 +++++++++++-------- 5 files changed, 51 insertions(+), 29 deletions(-) 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 6f2e33f23..ff9a99c6a 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -249,13 +249,17 @@ describe("image-upload", () => { }); it("should render tooltip when tooltip prop is provided", async () => { - await renderComponent({ overrideField: { tooltip: { onClick: onTooltipClick } } }); + await renderComponent({ overrideField: { tooltip: {} } }); expect(screen.getByTestId("field__tooltip")).toBeInTheDocument(); }); - it("should call onClick when tooltip is clicked", async () => { - await renderComponent({ overrideField: { tooltip: { onClick: onTooltipClick } } }); + it("should fire tooltip-click event when tooltip is clicked", async () => { + await renderComponent({ + overrideField: { tooltip: {} }, + eventType: "tooltip-click", + eventListener: onTooltipClick, + }); fireEvent.click(screen.getByTestId("field__tooltip")); @@ -263,13 +267,13 @@ describe("image-upload", () => { }); it("should render label text when label is provided", async () => { - await renderComponent({ overrideField: { tooltip: { label: "More info", onClick: onTooltipClick } } }); + 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", onClick: onTooltipClick } } }); + await renderComponent({ overrideField: { tooltip: { icon: "ICircleFillIcon" } } }); expect(screen.getByTestId("field__tooltip").querySelector("svg")).toBeInTheDocument(); }); 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 e3e66a5a1..c88d0fa95 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 @@ -34,9 +34,10 @@ export const TooltipWrapper = styled.button` border: none; padding: 0; cursor: pointer; + color: ${Colour["text-primary"]}; &:hover { - opacity: 0.8; + color: ${Colour["text-hover"]}; } `; @@ -46,7 +47,7 @@ export const TooltipIcon = styled.span` justify-content: center; width: 1rem; height: 1rem; - color: ${Colour["text-primary"]}; + color: inherit; svg { width: 100%; @@ -55,7 +56,7 @@ export const TooltipIcon = styled.span` `; export const TooltipLabel = styled(Typography.BodyMD)` - color: ${Colour["text-primary"]}; + color: inherit; `; export const Content = styled.div` 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 b10f26933..f7484f30c 100644 --- a/src/components/fields/image-upload/image-input/image-input.tsx +++ b/src/components/fields/image-upload/image-input/image-input.tsx @@ -32,7 +32,7 @@ interface IImageInputProps extends ISharedImageProps { validation: IImageUploadValidationRule[]; multiple?: boolean | undefined; warning?: string | undefined; - tooltip?: { label?: string | undefined; icon?: keyof typeof Icons | undefined; onClick: () => void } | undefined; + tooltip?: { label?: string | undefined; icon?: keyof typeof Icons | undefined } | undefined; } /** @@ -236,7 +236,7 @@ export const ImageInput = (props: IImageInputProps) => { dispatchFieldEvent("tooltip-click", id)} > {tooltip.label && {tooltip.label}} {tooltip.icon && renderTooltipIcon(tooltip.icon)} diff --git a/src/components/fields/image-upload/types.ts b/src/components/fields/image-upload/types.ts index 8e3ebc4fb..ce042d62d 100644 --- a/src/components/fields/image-upload/types.ts +++ b/src/components/fields/image-upload/types.ts @@ -34,7 +34,7 @@ export interface IImageUploadSchema capture?: TFileCapture | undefined; multiple?: boolean | undefined; imageReviewModalStyles?: string | undefined; - tooltip?: { label?: string | undefined; icon?: keyof typeof Icons | undefined; onClick: () => void } | undefined; + tooltip?: { label?: string | undefined; icon?: keyof typeof Icons | undefined } | undefined; } export interface ISharedImageProps { @@ -184,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: "tooltip-click", + 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 972d0dc9f..5fc9b7e10 100644 --- a/src/stories/3-fields/image-upload/image-upload.stories.tsx +++ b/src/stories/3-fields/image-upload/image-upload.stories.tsx @@ -1,7 +1,9 @@ import { action } from "@storybook/addon-actions"; import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; 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, @@ -182,7 +184,7 @@ const meta: Meta = { "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; onClick: () => void }", + summary: "{ label?: string; icon?: keyof typeof Icons }", }, defaultValue: { summary: null }, }, @@ -390,26 +392,33 @@ Multiple.args = { editImage: true, }; -export const WithTooltip: StoryFn = (args: IImageUploadSchema) => ( - = (args: IImageUploadSchema) => { + const id = "upload-with-tooltip"; + const formRef = useRef(); + const handleTooltipClick = (e: unknown) => action("tooltip-click")(e); + useEffect(() => { + const currentFormRef = formRef.current; + currentFormRef.addFieldEventListener("image-upload", "tooltip-click", id, handleTooltipClick); + return () => currentFormRef.removeFieldEventListener("image-upload", "tooltip-click", id, handleTooltipClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + -); + }} + /> + ); +}; WithTooltip.args = { label: "Provide images", uiType: "image-upload", From adb7a6bc34db1069737352fe2b826b72383f8584 Mon Sep 17 00:00:00 2001 From: Matthew Auw Date: Mon, 13 Apr 2026 20:48:59 +0800 Subject: [PATCH 5/5] [MOL-18749][MA] update tooltip event name to click-tooltip --- .../components/fields/image-upload/image-upload.spec.tsx | 4 ++-- .../fields/image-upload/image-input/image-input.styles.ts | 3 --- .../fields/image-upload/image-input/image-input.tsx | 2 +- src/components/fields/image-upload/types.ts | 2 +- src/stories/3-fields/image-upload/image-upload.stories.tsx | 6 +++--- 5 files changed, 7 insertions(+), 10 deletions(-) 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 ff9a99c6a..74f41e93e 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -254,10 +254,10 @@ describe("image-upload", () => { expect(screen.getByTestId("field__tooltip")).toBeInTheDocument(); }); - it("should fire tooltip-click event when tooltip is clicked", async () => { + it("should fire click-tooltip event when tooltip is clicked", async () => { await renderComponent({ overrideField: { tooltip: {} }, - eventType: "tooltip-click", + eventType: "click-tooltip", eventListener: onTooltipClick, }); 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 c88d0fa95..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 @@ -42,9 +42,6 @@ export const TooltipWrapper = styled.button` `; export const TooltipIcon = styled.span` - display: inline-flex; - align-items: center; - justify-content: center; width: 1rem; height: 1rem; color: inherit; 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 f7484f30c..48730d03c 100644 --- a/src/components/fields/image-upload/image-input/image-input.tsx +++ b/src/components/fields/image-upload/image-input/image-input.tsx @@ -236,7 +236,7 @@ export const ImageInput = (props: IImageInputProps) => { dispatchFieldEvent("tooltip-click", id)} + onClick={() => dispatchFieldEvent("click-tooltip", id)} > {tooltip.label && {tooltip.label}} {tooltip.icon && renderTooltipIcon(tooltip.icon)} diff --git a/src/components/fields/image-upload/types.ts b/src/components/fields/image-upload/types.ts index ce042d62d..46a6b5a2e 100644 --- a/src/components/fields/image-upload/types.ts +++ b/src/components/fields/image-upload/types.ts @@ -187,7 +187,7 @@ function imageUploadEvent( /** fired when the tooltip is clicked */ function imageUploadEvent( uiType: "image-upload", - type: "tooltip-click", + type: "click-tooltip", id: string, listener: TFieldEventListener, options?: boolean | AddEventListenerOptions | undefined 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 5fc9b7e10..3ba2ad1ba 100644 --- a/src/stories/3-fields/image-upload/image-upload.stories.tsx +++ b/src/stories/3-fields/image-upload/image-upload.stories.tsx @@ -395,11 +395,11 @@ Multiple.args = { export const WithTooltip: StoryFn = (args: IImageUploadSchema) => { const id = "upload-with-tooltip"; const formRef = useRef(); - const handleTooltipClick = (e: unknown) => action("tooltip-click")(e); + const handleTooltipClick = (e: unknown) => action("click-tooltip")(e); useEffect(() => { const currentFormRef = formRef.current; - currentFormRef.addFieldEventListener("image-upload", "tooltip-click", id, handleTooltipClick); - return () => currentFormRef.removeFieldEventListener("image-upload", "tooltip-click", id, handleTooltipClick); + 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 (