diff --git a/packages/shared/src/components/Markdown.tsx b/packages/shared/src/components/Markdown.tsx index c5f0ac1679..40c3457040 100644 --- a/packages/shared/src/components/Markdown.tsx +++ b/packages/shared/src/components/Markdown.tsx @@ -1,5 +1,10 @@ -import type { MouseEventHandler, ReactElement } from 'react'; -import React, { useCallback, useState } from 'react'; +import type { + KeyboardEvent, + MouseEvent, + MouseEventHandler, + ReactElement, +} from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { useQuery } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; @@ -10,6 +15,16 @@ import { useDomPurify } from '../hooks/useDomPurify'; import { getUserShortInfo } from '../graphql/users'; import { generateQueryKey, RequestKey } from '../lib/query'; +function isImageElement( + element: Element | EventTarget, +): element is HTMLImageElement { + return element instanceof HTMLImageElement; +} + +function openImageInNewTab(src: string): void { + window.open(src, '_blank', 'noopener,noreferrer'); +} + const UserEntityCard = dynamic(() => import('./cards/entity/UserEntityCard'), { ssr: false, }); @@ -47,6 +62,7 @@ export default function Markdown({ appendTooltipTo, }: MarkdownProps): ReactElement { const purify = useDomPurify(); + const containerRef = useRef(null); const [userId, setUserId] = useState(''); const [offset, setOffset] = useState([0, 0]); const [clearUser, cancelUserClearing] = useDebounceFn( @@ -61,6 +77,21 @@ export default function Markdown({ enabled: !!userId, }); + // Add accessibility attributes to images after render + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const images = container.querySelectorAll('img[src]'); + images.forEach((img) => { + img.setAttribute('tabindex', '0'); + img.setAttribute('role', 'button'); + img.setAttribute('aria-label', 'Open image in new tab'); + }); + }, [content]); + const onHoverHandler: MouseEventHandler = useCallback( (e) => { const element = e.target; @@ -81,6 +112,29 @@ export default function Markdown({ [cancelUserClearing, userId, clearUser], ); + const onImageClick = useCallback((e: MouseEvent) => { + const element = e.target; + + if (isImageElement(element) && element.src) { + e.stopPropagation(); + openImageInNewTab(element.src); + } + }, []); + + const onImageKeyDown = useCallback((e: KeyboardEvent) => { + const element = e.target; + + if ( + isImageElement(element) && + element.src && + (e.key === 'Enter' || e.key === ' ') + ) { + e.preventDefault(); + e.stopPropagation(); + openImageInNewTab(element.src); + } + }, []); + return ( } > diff --git a/packages/shared/src/components/markdown.module.css b/packages/shared/src/components/markdown.module.css index f66b4bdb68..27d54d0b06 100644 --- a/packages/shared/src/components/markdown.module.css +++ b/packages/shared/src/components/markdown.module.css @@ -84,7 +84,11 @@ @apply border-border-subtlest-secondary my-10; } & :where(img) { - @apply rounded-16 max-h-img-mobile tablet:max-h-img-desktop; + @apply rounded-16 max-h-img-mobile tablet:max-h-img-desktop cursor-pointer; + + &:focus-visible { + @apply outline-none ring-2 ring-accent-cabbage-default ring-offset-2 ring-offset-background-default; + } } & > :where(:first-child) { @apply mt-0;