diff --git a/src/sidebar/components/Annotation/AnnotationPublishControl.tsx b/src/sidebar/components/Annotation/AnnotationPublishControl.tsx index f35e9883012..0c1fa2e8968 100644 --- a/src/sidebar/components/Annotation/AnnotationPublishControl.tsx +++ b/src/sidebar/components/Annotation/AnnotationPublishControl.tsx @@ -7,10 +7,12 @@ import { MenuExpandIcon, } from '@hypothesis/frontend-shared'; import classnames from 'classnames'; +import { useRef } from 'preact/hooks'; import type { Group } from '../../../types/api'; import type { SidebarSettings } from '../../../types/config'; import { applyTheme } from '../../helpers/theme'; +import { useContentTruncated } from '../../hooks/use-content-truncated'; import { withServices } from '../../service-context'; import Menu from '../Menu'; import MenuItem from '../MenuItem'; @@ -71,13 +73,21 @@ function AnnotationPublishControl({ ); + const postButtonText = `Post to ${isPrivate ? 'Only Me' : group.name}`; + const postButtonRef = useRef(null); + const isButtonContentTruncated = useContentTruncated(postButtonRef); + return (
-
+
{/* This wrapper div is necessary because of peculiarities with Safari: see https://github.com/hypothesis/client/issues/2302 */} diff --git a/src/sidebar/components/Annotation/test/AnnotationPublishControl-test.js b/src/sidebar/components/Annotation/test/AnnotationPublishControl-test.js index ae56379ca2f..5af4b2fc7ba 100644 --- a/src/sidebar/components/Annotation/test/AnnotationPublishControl-test.js +++ b/src/sidebar/components/Annotation/test/AnnotationPublishControl-test.js @@ -8,6 +8,7 @@ import { mockImportedComponents, } from '@hypothesis/frontend-testing'; import { mount } from '@hypothesis/frontend-testing'; +import sinon from 'sinon'; import AnnotationPublishControl, { $imports, @@ -17,6 +18,7 @@ describe('AnnotationPublishControl', () => { let fakeGroup; let fakeSettings; let fakeApplyTheme; + let fakeUseContentTruncated; let fakeOnSave; let fakeOnCancel; @@ -54,12 +56,16 @@ describe('AnnotationPublishControl', () => { }; fakeApplyTheme = sinon.stub(); + fakeUseContentTruncated = sinon.stub().returns(false); $imports.$mock(mockImportedComponents()); $imports.$mock({ '../../helpers/theme': { applyTheme: fakeApplyTheme, }, + '../../hooks/use-content-truncated': { + useContentTruncated: fakeUseContentTruncated, + }, }); }); @@ -202,6 +208,16 @@ describe('AnnotationPublishControl', () => { }); }); + [true, false].forEach(isTruncated => { + it('adds title to publish button when its content is truncated', () => { + fakeUseContentTruncated.returns(isTruncated); + + const wrapper = createAnnotationPublishControl(); + + assert.equal(!!getPublishButton(wrapper).prop('title'), isTruncated); + }); + }); + it( 'should pass a11y checks', checkAccessibility({ diff --git a/src/sidebar/hooks/test/use-content-truncated-test.js b/src/sidebar/hooks/test/use-content-truncated-test.js new file mode 100644 index 00000000000..4d122a5c6f3 --- /dev/null +++ b/src/sidebar/hooks/test/use-content-truncated-test.js @@ -0,0 +1,76 @@ +import { mount } from '@hypothesis/frontend-testing'; +import { useRef } from 'preact/hooks'; + +import { useContentTruncated } from '../use-content-truncated'; + +describe('useContentTruncated', () => { + function FakeComponent({ children }) { + const truncatedElementRef = useRef(null); + const isTruncated = useContentTruncated(truncatedElementRef); + + return ( +
+
+ {children} +
+
{isTruncated ? 'Yes' : 'No'}
+
+ ); + } + + function createComponent(content) { + return mount({content}, { connected: true }); + } + + function isTruncated(wrapper) { + return wrapper.find('[data-testid="is-truncated"]').text() === 'Yes'; + } + + function waitForResize(element) { + return new Promise(resolve => { + const observer = new ResizeObserver(() => { + observer.disconnect(); + resolve(); + }); + observer.observe(element); + }); + } + + [ + { + content: 'foo', + shouldBeTruncated: false, + }, + { + content: 'foo'.repeat(1000), + shouldBeTruncated: true, + }, + ].forEach(({ content, shouldBeTruncated }) => { + it('checks if content is truncated once mounted', () => { + const wrapper = createComponent(content); + assert.equal(isTruncated(wrapper), shouldBeTruncated); + }); + }); + + it('checks if content is truncated when resized', async () => { + const wrapper = createComponent('some content'); + + // Content is initially not truncated + assert.isFalse(isTruncated(wrapper)); + + // When we resize the container to make it smaller, the content gets + // truncated + const container = wrapper.find('[data-testid="container"]').getDOMNode(); + const resizePromise = waitForResize(container); + container.style.width = '10px'; + + await resizePromise; + wrapper.update(); + + assert.isTrue(isTruncated(wrapper)); + }); +}); diff --git a/src/sidebar/hooks/use-content-truncated.ts b/src/sidebar/hooks/use-content-truncated.ts new file mode 100644 index 00000000000..c3126dfcb4a --- /dev/null +++ b/src/sidebar/hooks/use-content-truncated.ts @@ -0,0 +1,35 @@ +import type { RefObject } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; + +/** + * Determines if the content of an element is truncated. + * + * Useful to use with elements with hidden overflow and ellipsis text-overflow, + * to know if the content is truncated and the ellipsis is being shown. + * + * Be careful not to change the element's content based on this hooks result, or + * you could end up triggering an infinite render loop. + */ +export function useContentTruncated(elementRef: RefObject) { + const [isTruncated, setIsTruncated] = useState(false); + useEffect(() => { + const buttonEl = elementRef.current; + /* istanbul ignore next */ + if (!buttonEl) { + return () => {}; + } + + const checkIsTruncated = () => + setIsTruncated(buttonEl.scrollWidth > buttonEl.clientWidth); + + // Check immediately if the element's content is truncated, and then again + // every time it is resized + checkIsTruncated(); + const observer = new ResizeObserver(checkIsTruncated); + observer.observe(buttonEl); + + return () => observer.disconnect(); + }, [elementRef]); + + return isTruncated; +}