diff --git a/apps/comments-ui/src/components/content/buttons/MoreButton.tsx b/apps/comments-ui/src/components/content/buttons/MoreButton.tsx index 0b63d0e844..96a6c06672 100644 --- a/apps/comments-ui/src/components/content/buttons/MoreButton.tsx +++ b/apps/comments-ui/src/components/content/buttons/MoreButton.tsx @@ -10,7 +10,7 @@ type Props = { const MoreButton: React.FC = ({comment, toggleEdit}) => { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const {member, admin, pagination, comments} = useAppContext(); + const {member, admin} = useAppContext(); const isAdmin = !!admin; const toggleContextMenu = () => { @@ -21,10 +21,6 @@ const MoreButton: React.FC = ({comment, toggleEdit}) => { setIsContextMenuOpen(false); }; - // Check if this is the last comment and there's no more pagination - const isLastComment = (!pagination || pagination.total <= pagination.page * pagination.limit) && - comments[comments.length - 1]?.id === comment.id; - const show = (!!member && comment.status === 'published') || isAdmin; if (!show) { @@ -36,7 +32,7 @@ const MoreButton: React.FC = ({comment, toggleEdit}) => { - {isContextMenuOpen ? : null} + {isContextMenuOpen ? : null} ); }; diff --git a/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.test.jsx b/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.test.jsx new file mode 100644 index 0000000000..15c3971259 --- /dev/null +++ b/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.test.jsx @@ -0,0 +1,40 @@ +import CommentContextMenu from './CommentContextMenu'; +import React from 'react'; +import sinon from 'sinon'; +import {AppContext} from '../../../AppContext'; +import {buildComment} from '../../../../test/utils/fixtures'; +import {render, screen} from '@testing-library/react'; + +const contextualRender = (ui, {appContext, ...renderOptions}) => { + const contextWithDefaults = { + member: null, + dispatchAction: () => {}, + t: str => str, + ...appContext + }; + + return render( + {ui}, + renderOptions + ); +}; + +describe('', () => { + afterEach(() => { + sinon.restore(); + }); + + it('has display-below classes when in viewport', () => { + const comment = buildComment(); + contextualRender(, {appContext: {admin: true, labs: {commentImprovements: true}}}); + expect(screen.getByTestId('comment-context-menu-inner')).toHaveClass('top-0'); + }); + + it('has display-above classes when bottom is out of viewport', () => { + sinon.stub(HTMLElement.prototype, 'getBoundingClientRect').returns({bottom: 2000}); + + const comment = buildComment(); + contextualRender(, {appContext: {admin: true, labs: {commentImprovements: true}}}); + expect(screen.getByTestId('comment-context-menu-inner')).toHaveClass('bottom-full', 'mb-6'); + }); +}); diff --git a/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.tsx b/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.tsx index 3b82dae379..a4734953c7 100644 --- a/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.tsx +++ b/apps/comments-ui/src/components/content/context-menus/CommentContextMenu.tsx @@ -3,20 +3,30 @@ import AuthorContextMenu from './AuthorContextMenu'; import NotAuthorContextMenu from './NotAuthorContextMenu'; import {Comment, useAppContext, useLabs} from '../../../AppContext'; import {useEffect, useRef} from 'react'; +import {useOutOfViewportClasses} from '../../../utils/hooks'; type Props = { comment: Comment; close: () => void; toggleEdit: () => void; - isLastComment?: boolean; }; -const CommentContextMenu: React.FC = ({comment, close, toggleEdit, isLastComment}) => { +const CommentContextMenu: React.FC = ({comment, close, toggleEdit}) => { const {member, admin} = useAppContext(); const isAuthor = member && comment.member?.uuid === member?.uuid; const isAdmin = !!admin; const element = useRef(null); + const innerElement = useRef(null); const labs = useLabs(); - + + // By default display dropdown below but move above if that renders off-screen + // NOTE: innerElement ref is only set when commentImprovements flag is enabled + useOutOfViewportClasses(innerElement, { + bottom: { + default: 'top-0', + outOfViewport: 'bottom-full mb-6' + } + }); + useEffect(() => { const listener = () => { close(); @@ -79,8 +89,8 @@ const CommentContextMenu: React.FC = ({comment, close, toggleEdit, isLast return ( labs.commentImprovements ? ( -
-
+
+
{contextMenu}
diff --git a/apps/comments-ui/src/components/content/context-menus/NotAuthorContextMenu.tsx b/apps/comments-ui/src/components/content/context-menus/NotAuthorContextMenu.tsx index 68641901e4..3275b92a4e 100644 --- a/apps/comments-ui/src/components/content/context-menus/NotAuthorContextMenu.tsx +++ b/apps/comments-ui/src/components/content/context-menus/NotAuthorContextMenu.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {useAppContext} from '../../../AppContext'; +import {Comment, useAppContext} from '../../../AppContext'; type Props = { comment: Comment; diff --git a/apps/comments-ui/src/utils/hooks.test.tsx b/apps/comments-ui/src/utils/hooks.test.tsx new file mode 100644 index 0000000000..68f0cf515c --- /dev/null +++ b/apps/comments-ui/src/utils/hooks.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import sinon from 'sinon'; +import {fireEvent, render, screen} from '@testing-library/react'; +import {useOutOfViewportClasses} from './hooks'; + +describe('useOutOfViewportClasses', () => { + const classes = { + top: {default: 'default-top', outOfViewport: 'out-top'}, + bottom: {default: 'default-bottom', outOfViewport: 'out-bottom'}, + left: {default: 'default-left', outOfViewport: 'out-left'}, + right: {default: 'default-right', outOfViewport: 'out-right'} + }; + + const TestComponent = () => { + const ref = React.useRef(null); + useOutOfViewportClasses(ref, classes); + + // eslint-disable-next-line i18next/no-literal-string + return
Test element
; + }; + + afterEach(() => { + sinon.restore(); + }); + + it('should apply default classes on mount when in viewport', () => { + render(); + + const element = screen.getByTestId('test-element'); + expect(element).toHaveClass('default-top', 'default-bottom', 'default-left', 'default-right'); + }); + + it('should apply outOfViewport classes on mount when out of viewport', () => { + sinon.stub(HTMLElement.prototype, 'getBoundingClientRect').returns({ + top: -100, // out of viewport + bottom: 2000, // out of viewport (jest-dom default height: 768) + left: -5, // out of viewport + right: 2000, // out of viewport (jest-dom default width: 1024) + width: 100, + height: 50, + x: 0, + y: 0, + toJSON: () => ({}) + }); + + render(); + + const element = screen.getByTestId('test-element'); + expect(element).toHaveClass('out-top', 'out-bottom', 'out-left', 'out-right'); + }); + + it('should apply outOfViewport classes when element moves out of viewport on resize', () => { + render(); + + const element = screen.getByTestId('test-element'); + expect(element).toHaveClass('default-top', 'default-bottom', 'default-left', 'default-right'); + + sinon.stub(HTMLElement.prototype, 'getBoundingClientRect').returns({ + top: -100, // out of viewport + bottom: 2000, // out of viewport (jest-dom default height: 768) + left: -5, // out of viewport + right: 2000, // out of viewport (jest-dom default width: 1024) + width: 100, + height: 50, + x: 0, + y: 0, + toJSON: () => ({}) + }); + + fireEvent.resize(window); + + expect(element).toHaveClass('out-top', 'out-bottom', 'out-left', 'out-right'); + }); +}); diff --git a/apps/comments-ui/src/utils/hooks.ts b/apps/comments-ui/src/utils/hooks.ts index bdf0b4f8db..43eac04738 100644 --- a/apps/comments-ui/src/utils/hooks.ts +++ b/apps/comments-ui/src/utils/hooks.ts @@ -2,7 +2,7 @@ import {CommentsEditorConfig, getEditorConfig} from './editor'; import {Editor, useEditor as useTiptapEditor} from '@tiptap/react'; import {formatRelativeTime} from './helpers'; import {useAppContext} from '../AppContext'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; /** * Execute a callback when a ref is set and unset. @@ -76,3 +76,81 @@ export function useEditor(editorConfig: CommentsEditorConfig, initialHasContent hasContent }; } + +type OutOfViewport = { + top: boolean; + bottom: boolean; + left: boolean; + right: boolean; +} +type OutOfViewportClassOptions = { + default: string; + outOfViewport: string; +} +type OutOfViewportClasses = { + top?: OutOfViewportClassOptions; + bottom?: OutOfViewportClassOptions; + left?: OutOfViewportClassOptions; + right?: OutOfViewportClassOptions; +}; +// TODO: This does not currently handle the case where the element is outOfViewport for both top&bottom or left&right +export function useOutOfViewportClasses(ref: React.RefObject, classes: OutOfViewportClasses) { + // Add/Remove classes directly on the element based on whether it's out of the viewport + // Modifies element classes directly in DOM so it's compatible with useLayoutEffect + const applyDefaultClasses = useCallback(() => { + if (ref.current) { + for (const value of Object.values(classes)) { + ref.current.classList.add(...value.default.split(' ')); + ref.current.classList.remove(...value.outOfViewport.split(' ')); + } + } + }, [ref, classes]); + + const applyOutOfViewportClasses = useCallback((outOfViewport: OutOfViewport) => { + if (ref.current) { + for (const [side, sideClasses] of Object.entries(classes)) { + if (outOfViewport[side as keyof OutOfViewport]) { + ref.current.classList.add(...sideClasses.outOfViewport.split(' ')); + ref.current.classList.remove(...sideClasses.default.split(' ')); + } else { + ref.current.classList.add(...sideClasses.default.split(' ')); + ref.current.classList.remove(...sideClasses.outOfViewport.split(' ')); + } + } + } + }, [ref, classes]); + + const updateOutOfViewportClasses = useCallback(() => { + if (ref.current) { + // Handle element being inside an iframe + const _document = ref.current.ownerDocument; + const _window = _document.defaultView || window; + + // Reset classes so we can re-calculate without any previous re-positioning affecting the calcs + applyDefaultClasses(); + + const bounding = ref.current.getBoundingClientRect(); + const outOfViewport = { + top: bounding.top < 0, + bottom: bounding.bottom > (_window.innerHeight || _document.documentElement.clientHeight), + left: bounding.left < 0, + right: bounding.right > (_window.innerWidth || _document.documentElement.clientWidth) + }; + + applyOutOfViewportClasses(outOfViewport); + } + }, [ref]); + + // Layout effect needed here to avoid flicker of the default position before + // repositioning the element + useLayoutEffect(() => { + updateOutOfViewportClasses(); + }, [ref]); + + useEffect(() => { + window.addEventListener('resize', updateOutOfViewportClasses); + return () => { + window.removeEventListener('resize', updateOutOfViewportClasses); + }; + }, []); +}