mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
💬 style: Enhance Tooltip with HTML support and Improve Styling (#8915)
* ✨ feat: Enhance Tooltip component with HTML support and styling improvements * ✨ feat: Integrate DOMPurify for HTML sanitization in Tooltip component
This commit is contained in:
parent
8238fb49e0
commit
e6fa01d514
5 changed files with 71 additions and 54 deletions
|
@ -32,13 +32,14 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
|
enableHTML={true}
|
||||||
description={config.description || ''}
|
description={config.description || ''}
|
||||||
render={
|
render={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor={name} className="text-sm font-medium">
|
<Label htmlFor={name} className="text-sm font-medium">
|
||||||
{config.title}
|
{config.title}
|
||||||
</Label>
|
</Label>
|
||||||
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />
|
<CircleHelpIcon className="h-6 w-6 cursor-help text-text-secondary transition-colors hover:text-text-primary" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
61
package-lock.json
generated
61
package-lock.json
generated
|
@ -2236,20 +2236,6 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api/node_modules/express-rate-limit": {
|
|
||||||
"version": "7.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz",
|
|
||||||
"integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/express-rate-limit"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"express": "4 || 5 || ^5.0.0-beta.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"api/node_modules/express-session": {
|
"api/node_modules/express-session": {
|
||||||
"version": "1.18.2",
|
"version": "1.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
||||||
|
@ -2510,38 +2496,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api/node_modules/mongodb-connection-string-url": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/whatwg-url": "^11.0.2",
|
|
||||||
"whatwg-url": "^13.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"api/node_modules/mongodb-connection-string-url/node_modules/tr46": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
|
||||||
"dependencies": {
|
|
||||||
"punycode": "^2.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"api/node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
|
|
||||||
"version": "13.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
|
||||||
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "^4.1.1",
|
|
||||||
"webidl-conversions": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"api/node_modules/mongoose": {
|
"api/node_modules/mongoose": {
|
||||||
"version": "8.12.1",
|
"version": "8.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz",
|
||||||
|
@ -29450,7 +29404,7 @@
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "2.0.10",
|
"version": "2.0.10",
|
||||||
|
@ -33161,6 +33115,16 @@
|
||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"peer": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
@ -51507,7 +51471,7 @@
|
||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@librechat/client",
|
"name": "@librechat/client",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-alias": "^5.1.0",
|
"@rollup/plugin-alias": "^5.1.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.2",
|
"@rollup/plugin-commonjs": "^25.0.2",
|
||||||
|
@ -51560,6 +51524,7 @@
|
||||||
"@tanstack/react-virtual": "^3.0.0",
|
"@tanstack/react-virtual": "^3.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
"framer-motion": "^12.23.6",
|
"framer-motion": "^12.23.6",
|
||||||
"i18next": "^24.2.2 || ^25.3.2",
|
"i18next": "^24.2.2 || ^25.3.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/client",
|
"name": "@librechat/client",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"description": "React components for LibreChat",
|
"description": "React components for LibreChat",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.es.js",
|
"module": "dist/index.es.js",
|
||||||
|
@ -54,6 +54,7 @@
|
||||||
"@react-spring/web": "^10.0.1",
|
"@react-spring/web": "^10.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
"framer-motion": "^12.23.6",
|
"framer-motion": "^12.23.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
|
|
@ -11,6 +11,16 @@
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
color: black;
|
color: black;
|
||||||
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.25);
|
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.25);
|
||||||
|
/* Enhanced layout for longer descriptions */
|
||||||
|
max-width: 320px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tooltip {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip:where(.dark, .dark *) {
|
.tooltip:where(.dark, .dark *) {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import * as Ariakit from '@ariakit/react';
|
import * as Ariakit from '@ariakit/react';
|
||||||
|
import { forwardRef, useId, useMemo } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { forwardRef, useMemo } from 'react';
|
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import './Tooltip.css';
|
import './Tooltip.css';
|
||||||
|
|
||||||
|
@ -8,18 +9,47 @@ interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
|
||||||
description: string;
|
description: string;
|
||||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
className?: string;
|
className?: string;
|
||||||
focusable?: boolean;
|
|
||||||
role?: string;
|
role?: string;
|
||||||
|
enableHTML?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
|
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
|
||||||
{ description, side = 'top', className, role, ...props },
|
{ description, side = 'top', className, role, enableHTML = false, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const tooltip = Ariakit.useTooltipStore({ placement: side });
|
const tooltip = Ariakit.useTooltipStore({ placement: side });
|
||||||
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
|
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
|
||||||
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
|
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
const sanitizer = useMemo(() => {
|
||||||
|
const instance = DOMPurify();
|
||||||
|
instance.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName && node.tagName === 'A') {
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return instance;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sanitizedHTML = useMemo(() => {
|
||||||
|
if (!enableHTML) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return sanitizer.sanitize(description, {
|
||||||
|
ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'],
|
||||||
|
ALLOWED_ATTR: ['href', 'class', 'target', 'rel'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
ALLOW_ARIA_ATTR: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sanitization failed', error);
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}, [enableHTML, description, sanitizer]);
|
||||||
|
|
||||||
const { x, y } = useMemo(() => {
|
const { x, y } = useMemo(() => {
|
||||||
const dir = placement.split('-')[0];
|
const dir = placement.split('-')[0];
|
||||||
switch (dir) {
|
switch (dir) {
|
||||||
|
@ -49,6 +79,7 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={role}
|
role={role}
|
||||||
|
aria-describedby={id}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn('cursor-pointer', className)}
|
className={cn('cursor-pointer', className)}
|
||||||
/>
|
/>
|
||||||
|
@ -58,6 +89,7 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
|
||||||
gutter={4}
|
gutter={4}
|
||||||
alwaysVisible
|
alwaysVisible
|
||||||
className="tooltip"
|
className="tooltip"
|
||||||
|
id={id}
|
||||||
render={
|
render={
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x, y }}
|
initial={{ opacity: 0, x, y }}
|
||||||
|
@ -67,7 +99,15 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Ariakit.TooltipArrow />
|
<Ariakit.TooltipArrow />
|
||||||
{description}
|
{enableHTML ? (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizedHTML,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
description
|
||||||
|
)}
|
||||||
</Ariakit.Tooltip>
|
</Ariakit.Tooltip>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue