diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index cae4152..e5d0b5d 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -61,6 +61,7 @@ export type { EmbeddedFlowExecuteRequestConfig, FlowExecutionError, ConsentAttributeElement, + PromptElement, ConsentPurposeDecision, ConsentDecisions, ConsentPurposeData, diff --git a/packages/react/src/components/adapters/Consent.tsx b/packages/react/src/components/adapters/Consent.tsx index 42bf87f..c64640d 100644 --- a/packages/react/src/components/adapters/Consent.tsx +++ b/packages/react/src/components/adapters/Consent.tsx @@ -16,10 +16,19 @@ * under the License. */ -import {type ConsentPurposeData} from '@thunderid/browser'; -import {FC, ReactNode} from 'react'; -import ConsentCheckboxList from './ConsentCheckboxList'; +import { + type ConsentPurposeData, + FlowMetadataResponse, + PromptElement, + resolveFlowTemplateLiterals, +} from '@thunderid/browser'; +import {type ChangeEvent, FC, ReactNode} from 'react'; +import ConsentCheckboxList, {getConsentOptionalKey} from './ConsentCheckboxList'; import Typography from '../primitives/Typography/Typography'; +import Toggle from '../primitives/Toggle/Toggle'; +import {Info} from '../primitives/Icons'; +import Tooltip from '../primitives/Tooltip/Tooltip'; +import {UseTranslation} from '../../hooks/useTranslation'; /** * Backward-compatible consent purpose type exported by @thunderid/react. @@ -40,6 +49,16 @@ export interface ConsentRenderProps { purposes: ConsentPurposeData[]; } +/** + * Interface for consent configuration + */ +export interface ConsentConfig { + essential?: string; + optional?: string; + essentialInfo?: string; + optionalInfo?: string; +} + /** * Props for the Consent component. */ @@ -73,14 +92,80 @@ export interface ConsentProps { * Callback invoked when a user toggles an optional attribute. */ onInputChange: (name: string, value: string) => void; + /** + * Config to modified detail in consent page + */ + config?: Record; + + /** + * Config of meta response + */ + meta?: FlowMetadataResponse | null; + + /** + * translation data + */ + t?: UseTranslation['t']; } +const defaultConfig: Required> = { + essential: 'Essential Attributtes', + optional: 'Optional Attributes', +}; + /** * Consent component renders the list of purposes and their associated attributes (essential and optional) * based on the data provided by the backend. It allows users to toggle optional attributes while essential * attributes are displayed as read-only. */ -const Consent: FC = ({consentData, formValues, onInputChange, children}: ConsentProps) => { +const Consent: FC = ({ + consentData, + formValues, + config: suppliedConfig = {}, + onInputChange, + children, + meta, + t, +}: ConsentProps) => { + /** Resolve any remaining {{t()}} or {{meta()}} template expressions in a string at render time. */ + const resolve = (text: string | undefined): string => { + if (!text || (!t && !meta)) { + return text || ''; + } + return resolveFlowTemplateLiterals(text, {meta, t: t || ((k: string): string => k)}); + }; + + const config: ConsentConfig = {...defaultConfig, ...suppliedConfig}; + const essentialInfo = typeof config.essentialInfo === 'string' ? resolve(config.essentialInfo.trim()) : ''; + const optionalInfo = typeof config.optionalInfo === 'string' ? resolve(config.optionalInfo.trim()) : ''; + const essentialLabel = resolve(config['essential']); + const optionalLabel = resolve(config['optional']); + + /** + * Method to check whether master toggle button is checked or not + * @param purpose Purpose object + * @param checked boolean variable to check, whether toggle is checked or unchecked + */ + const handleChange = (purpose: ConsentPurposeData, checked: boolean): void => { + const checkValue = checked ? 'true' : 'false'; + purpose.optional.map((opt: PromptElement) => { + const key: string = getConsentOptionalKey(purpose.purposeId, opt.name); + onInputChange(key, checkValue); + }); + }; + + /** + * Check all optional claims are selected or not + * @param purpose Purpose object + * @returns boolean value to denote all optional claims are selected + */ + const checkOptValue = (purpose: ConsentPurposeData): boolean => { + return purpose.optional.every((opt: PromptElement) => { + const key: string = getConsentOptionalKey(purpose.purposeId, opt.name); + return formValues[key] === 'true'; + }); + }; + if (!consentData) return null; let purposes: ConsentPurposeData[] = []; @@ -115,9 +200,16 @@ const Consent: FC = ({consentData, formValues, onInputChange, chil {purpose.essential && purpose.essential.length > 0 && (
- - Essential Attributes - +
+ + {essentialLabel} + + {essentialInfo !== '' && ( + + + + )} +
= ({consentData, formValues, onInputChange, chil {purpose.optional && purpose.optional.length > 0 && (
- - {purpose.type === 'permissions' ? 'Permissions' : 'Optional Attributes'} - +
+
+ + {purpose.type === 'permissions' ? 'Permissions' : optionalLabel} + + {optionalInfo !== '' && ( + + + + )} +
+ ): void => handleChange(purpose, e.target.checked)} + /> +
=> `, listItem: css` padding: 0 0.25rem; + margin-bottom: 4px; `, listRow: css` display: flex; diff --git a/packages/react/src/components/adapters/ConsentCheckboxList.tsx b/packages/react/src/components/adapters/ConsentCheckboxList.tsx index 815506a..6d60c11 100644 --- a/packages/react/src/components/adapters/ConsentCheckboxList.tsx +++ b/packages/react/src/components/adapters/ConsentCheckboxList.tsx @@ -21,7 +21,6 @@ import {type ConsentPurposeData, withVendorCSSClassPrefix, bem} from '@thunderid import {type ChangeEvent, FC, ReactNode} from 'react'; import useStyles from './ConsentCheckboxList.styles'; import useTheme from '../../contexts/Theme/useTheme'; -import Divider from '../primitives/Divider/Divider'; import Toggle from '../primitives/Toggle/Toggle'; import Typography from '../primitives/Typography/Typography'; @@ -128,8 +127,8 @@ const ConsentCheckboxList: FC = ({ return true; } const key: string = getConsentOptionalKey(purpose.purposeId, attrName); - // Default to opted-in (true) when there's no explicit form value - return formValues[key] !== 'false'; + // Default to be false when there's no explicit form value + return formValues[key] === 'true'; }; const handleChange = (attrName: string, checked: boolean): void => { @@ -172,20 +171,21 @@ const ConsentCheckboxList: FC = ({ {attr}
- ): void => handleChange(attr, e.target.checked) - } - /> + {isEssential ? ( + Required + ) : ( + ): void => handleChange(attr, e.target.checked) + } + /> + )}
- ); })} diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index eb1d1c5..61f6765 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -394,7 +394,7 @@ const createAuthComponentFromFlow = ( ...p.essential.map((e): ConsentAttributeElement => ({approved: !isDeny, name: e.name})), ...p.optional.map( (e): ConsentAttributeElement => ({ - approved: isDeny ? false : formValues[getConsentOptionalKey(p.purposeId, e.name)] !== 'false', + approved: !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true', name: e.name, }), ), @@ -772,6 +772,9 @@ const createAuthComponentFromFlow = ( consentData={consentPromptRawData as any} formValues={formValues} onInputChange={onInputChange} + config={component.config} + meta={options.meta} + t={options.t} /> ); } diff --git a/packages/react/src/components/primitives/Tooltip/Tooltip.styles.ts b/packages/react/src/components/primitives/Tooltip/Tooltip.styles.ts new file mode 100644 index 0000000..3c3f6ba --- /dev/null +++ b/packages/react/src/components/primitives/Tooltip/Tooltip.styles.ts @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {css, keyframes} from '@emotion/css'; +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; + +const useStyles = (theme: Theme, colorScheme: string): Record => + useMemo(() => { + const fadeInAnimation = keyframes` + from { opacity: 0; } + to { opacity: 1; } + `; + + const containerStyles: string = css` + position: relative; + display: inline-block; + `; + + // white-space: nowrap; + const boxStyles: string = css` + position: absolute; + background-color: #333; + color: #fff; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.85rem; + z-index: 1000; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.15); + animation: ${fadeInAnimation} 0.15s ease-in-out; + + min-width: 250px; + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + text-align: start; + `; + + const topStyles: string = css` + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 8px; + `; + + const bottomStyles: string = css` + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 8px; + `; + + const leftStyles: string = css` + right: 100%; + top: 50%; + transform: translateY(-50%); + margin-right: 8px; + `; + + const rightStyles: string = css` + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 8px; + `; + + const arrowStyles: string = css` + position: absolute; + width: 0; + height: 0; + border-style: solid; + `; + + const topArrowStyles: string = css` + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 5px 5px 0 5px; + border-color: #333 transparent transparent transparent; + `; + + const bottomArrowStyles: string = css` + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 0 5px 5px 5px; + border-color: transparent transparent #333 transparent; + `; + + const leftArrowStyles: string = css` + left: 100%; + top: 50%; + transform: translateY(-50%); + border-width: 5px 0 5px 5px; + border-color: transparent transparent transparent #333; + `; + + const rightArrowStyles: string = css` + right: 100%; + top: 50%; + transform: translateY(-50%); + border-width: 5px 5px 5px 0; + border-color: transparent #333 transparent transparent; + `; + + return { + container: containerStyles, + box: boxStyles, + top: topStyles, + bottom: bottomStyles, + left: leftStyles, + right: rightStyles, + arrow: arrowStyles, + 'top-arrow': topArrowStyles, + 'bottom-arrow': bottomArrowStyles, + 'left-arrow': leftArrowStyles, + 'right-arrow': rightArrowStyles, + }; + }, [theme, colorScheme]); + +export default useStyles; diff --git a/packages/react/src/components/primitives/Tooltip/Tooltip.tsx b/packages/react/src/components/primitives/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..2baf002 --- /dev/null +++ b/packages/react/src/components/primitives/Tooltip/Tooltip.tsx @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {cx} from '@emotion/css'; +import {withVendorCSSClassPrefix, bem} from '@thunderid/browser'; +import {FC, InputHTMLAttributes, ReactNode, useId, useState, KeyboardEvent} from 'react'; +import useStyles from './Tooltip.styles'; +import useTheme from '../../../contexts/Theme/useTheme'; + +/** + * Props for the Tooltip component. + */ +export interface Tooltiprops extends Omit, 'className' | 'type'> { + className?: string; + helperText?: string; + position?: 'top' | 'bottom' | 'left' | 'right'; + children?: ReactNode; + ariaLabel?: string; +} + +/** + * A Tooltip component that convert any react or html element to a tooltip. + * When a user hover on it, they will see a small tooltip with some info. + * + * It will take a children, which can be a react element or html element. + * + * @param props - Props for the Tooltip component + * @returns A JSX element representing the Tooltip + */ +const Tooltip: FC = ({ + className = '', + helperText = '', + style = {}, + position = 'bottom', + children = null, + ariaLabel = 'More Info', + ...rest +}: Tooltiprops) => { + const {theme, colorScheme}: ReturnType = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const [isVisible, setIsVisible] = useState(false); + + // Unique ID to connect trigger button to tooltip box via ARIA + const tooltipId = useId(); + + // Close tooltip if user presses ESC while focused + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isVisible) { + setIsVisible(false); + } + + // Optional: Prevent Space key from scrolling the page + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setIsVisible((prev) => !prev); + } + }; + return ( +
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} /* Trigger on Tab Focus */ + onBlur={() => setIsVisible(false)} /* Hide on Tab Blur */ + onKeyDown={handleKeyDown} /* Allow ESC to dismiss */ + {...rest} + > + {children} + {isVisible && helperText && ( + + )} +
+ ); +}; + +export default Tooltip; diff --git a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts index fbb35bf..6aae957 100644 --- a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts @@ -233,7 +233,7 @@ const createAuthComponentFromFlow = ( ...p.essential.map((e): ConsentAttributeElement => ({approved: !isDeny, name: e.name})), ...p.optional.map( (e): ConsentAttributeElement => ({ - approved: isDeny ? false : formValues[getConsentOptionalKey(p.purposeId, e.name)] !== 'false', + approved: !isDeny && formValues[getConsentOptionalKey(p.purposeId, e.name)] === 'true', name: e.name, }), ),