diff --git a/src/components/Excerpt.tsx b/src/components/Excerpt.tsx new file mode 100644 index 0000000..11eb806 --- /dev/null +++ b/src/components/Excerpt.tsx @@ -0,0 +1,224 @@ +import { LinkButton } from '@hypothesis/frontend-shared'; +import classnames from 'classnames'; +import type { ComponentChildren, JSX } from 'preact'; +import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks'; + +import { observeElementSize } from '../utils/observe-element-size'; + +type InlineControlsProps = { + isCollapsed: boolean; + setCollapsed: (collapsed: boolean) => void; + linkStyle: JSX.CSSProperties; +}; + +/** + * An optional toggle link at the bottom of an excerpt which controls whether + * it is expanded or collapsed. + */ +function InlineControls({ + isCollapsed, + setCollapsed, + linkStyle, +}: InlineControlsProps) { + return ( +
+
+ setCollapsed(!isCollapsed)} + expanded={!isCollapsed} + title="Toggle visibility of full excerpt text" + style={linkStyle} + underline="always" + inline + > + {isCollapsed ? 'More' : 'Less'} + +
+
+ ); +} + +const noop = () => {}; + +export type ExcerptProps = { + children?: ComponentChildren; + + /** + * If `true`, the excerpt provides internal controls to expand and collapse + * the content. If `false`, the caller sets the collapsed state via the + * `collapse` prop. When using inline controls, the excerpt is initially + * collapsed. + */ + inlineControls?: boolean; + + /** + * If the content should be truncated if its height exceeds + * `collapsedHeight + overflowThreshold`. This prop is only used if + * `inlineControls` is false. + */ + collapse?: boolean; + + /** + * Maximum height of the container, in pixels, when it is collapsed. + */ + collapsedHeight: number; + + /** + * An additional margin of pixels by which the content height can exceed + * `collapsedHeight` before it becomes collapsible. + */ + overflowThreshold?: number; + + /** + * Called when the content height exceeds or falls below + * `collapsedHeight + overflowThreshold`. + */ + onCollapsibleChanged?: (isCollapsible: boolean) => void; + + /** + * When `inlineControls` is `false`, this function is called when the user + * requests to expand the content by clicking a zone at the bottom of the + * container. + */ + onToggleCollapsed?: (collapsed: boolean) => void; + + /** + * Additional styles to pass to the inline controls element. + * Ignored if inlineControls is `false`. + */ + inlineControlsLinkStyle?: JSX.CSSProperties; +}; + +/** + * A container which truncates its content when they exceed a specified height. + * + * The collapsed state of the container can be handled either via internal + * controls (if `inlineControls` is `true`) or by the caller using the + * `collapse` prop. + */ +export default function Excerpt({ + children, + collapse = false, + collapsedHeight, + inlineControls = true, + onCollapsibleChanged = noop, + onToggleCollapsed = noop, + overflowThreshold = 0, + inlineControlsLinkStyle = {}, +}: ExcerptProps) { + const [collapsedByInlineControls, setCollapsedByInlineControls] = + useState(true); + + const contentElement = useRef(null); + + // Measured height of `contentElement` in pixels + const [contentHeight, setContentHeight] = useState(0); + + // Update the measured height of the content container after initial render, + // and when the size of the content element changes. + const updateContentHeight = useCallback(() => { + const newContentHeight = contentElement.current!.clientHeight; + setContentHeight(newContentHeight); + + // prettier-ignore + const isCollapsible = + newContentHeight > (collapsedHeight + overflowThreshold); + onCollapsibleChanged(isCollapsible); + }, [collapsedHeight, onCollapsibleChanged, overflowThreshold]); + + useLayoutEffect(() => { + const cleanup = observeElementSize( + contentElement.current!, + updateContentHeight, + ); + updateContentHeight(); + return cleanup; + }, [updateContentHeight]); + + // Render the (possibly truncated) content and controls for + // expanding/collapsing the content. + // prettier-ignore + const isOverflowing = contentHeight > (collapsedHeight + overflowThreshold); + const isCollapsed = inlineControls ? collapsedByInlineControls : collapse; + const isExpandable = isOverflowing && isCollapsed; + + const contentStyle: Record = {}; + if (contentHeight !== 0) { + contentStyle['max-height'] = isExpandable ? collapsedHeight : contentHeight; + } + + const setCollapsed = (collapsed: boolean) => + inlineControls + ? setCollapsedByInlineControls(collapsed) + : onToggleCollapsed(collapsed); + + return ( +
+
+ {children} +
+
setCollapsed(false)} + className={classnames( + // This element provides a clickable area at the bottom of an + // expandable excerpt to expand it. + 'transition-[opacity] duration-150 ease-linear', + 'absolute w-full bottom-0 h-touch-minimum', + { + // For expandable excerpts not using inlineControls, style this + // element with a custom shadow-like gradient + 'bg-gradient-to-b from-excerpt-stop-1 via-excerpt-stop-2 to-excerpt-stop-3': + !inlineControls && isExpandable, + 'bg-none': inlineControls, + // Don't make this shadow visible OR clickable if there's nothing + // to do here (the excerpt isn't expandable) + 'opacity-0 pointer-events-none': !isExpandable, + }, + )} + title="Show the full excerpt" + /> + {isOverflowing && inlineControls && ( + + )} +
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index b07c564..38901c5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,7 @@ export { default as AnnotationGroupInfo } from './AnnotationGroupInfo'; export { default as AnnotationShareControl } from './AnnotationShareControl'; export { default as AnnotationTimestamps } from './AnnotationTimestamps'; export { default as AnnotationUser } from './AnnotationUser'; +export { default as Excerpt } from './Excerpt'; export { default as MarkdownView } from './MarkdownView'; export { default as MentionPopoverContent } from './MentionPopoverContent'; export { default as ModerationStatusSelect } from './ModerationStatusSelect'; @@ -13,6 +14,7 @@ export type { AnnotationGroupInfoProps } from './AnnotationGroupInfo'; export type { AnnotationShareControlProps } from './AnnotationShareControl'; export type { AnnotationTimestampsProps } from './AnnotationTimestamps'; export type { AnnotationUserProps } from './AnnotationUser'; +export type { ExcerptProps } from './Excerpt'; export type { MarkdownViewProps } from './MarkdownView'; export type { MentionPopoverContentProps } from './MentionPopoverContent'; export type { ModerationStatusSelectProps } from './ModerationStatusSelect'; diff --git a/src/components/test/Excerpt-test.js b/src/components/test/Excerpt-test.js new file mode 100644 index 0000000..ca9b2a8 --- /dev/null +++ b/src/components/test/Excerpt-test.js @@ -0,0 +1,173 @@ +import { checkAccessibility } from '@hypothesis/frontend-testing'; +import { mount } from '@hypothesis/frontend-testing'; +import { act } from 'preact/test-utils'; + +import Excerpt, { $imports } from '../Excerpt'; + +describe('Excerpt', () => { + const SHORT_DIV =
; + const TALL_DIV = ( +
+ foo bar +
+ ); + const DEFAULT_CONTENT = default content; + + let fakeObserveElementSize; + + function createExcerpt(props = {}, content = DEFAULT_CONTENT) { + return mount( + + {content} + , + { connected: true }, + ); + } + + beforeEach(() => { + fakeObserveElementSize = sinon.stub(); + $imports.$mock({ + '../utils/observe-element-size': { + observeElementSize: fakeObserveElementSize, + }, + }); + }); + + afterEach(() => { + $imports.$restore(); + }); + + function getExcerptHeight(wrapper) { + return wrapper.find('[data-testid="excerpt-container"]').prop('style')[ + 'max-height' + ]; + } + + it('renders content in container', () => { + const wrapper = createExcerpt(); + const contentEl = wrapper.find('[data-testid="excerpt-content"]'); + assert.include(contentEl.html(), 'default content'); + }); + + it('truncates content if it exceeds `collapsedHeight` + `overflowThreshold`', () => { + const wrapper = createExcerpt({}, TALL_DIV); + assert.equal(getExcerptHeight(wrapper), 40); + }); + + it('does not truncate content if it does not exceed `collapsedHeight` + `overflowThreshold`', () => { + const wrapper = createExcerpt({}, SHORT_DIV); + assert.equal(getExcerptHeight(wrapper), 5); + }); + + it('updates the collapsed state when the content height changes', () => { + const wrapper = createExcerpt({}, SHORT_DIV); + assert.called(fakeObserveElementSize); + + const contentElem = fakeObserveElementSize.getCall(0).args[0]; + const sizeChangedCallback = fakeObserveElementSize.getCall(0).args[1]; + act(() => { + contentElem.style.height = '400px'; + sizeChangedCallback(); + }); + wrapper.update(); + + assert.equal(getExcerptHeight(wrapper), 40); + + act(() => { + contentElem.style.height = '10px'; + sizeChangedCallback(); + }); + wrapper.update(); + + assert.equal(getExcerptHeight(wrapper), 10); + }); + + it('calls `onCollapsibleChanged` when collapsibility changes', () => { + const onCollapsibleChanged = sinon.stub(); + createExcerpt({ onCollapsibleChanged }, SHORT_DIV); + + const contentElem = fakeObserveElementSize.getCall(0).args[0]; + const sizeChangedCallback = fakeObserveElementSize.getCall(0).args[1]; + act(() => { + contentElem.style.height = '400px'; + sizeChangedCallback(); + }); + + assert.calledWith(onCollapsibleChanged, true); + }); + + it('calls `onToggleCollapsed` when user clicks in bottom area to expand excerpt', () => { + const onToggleCollapsed = sinon.stub(); + const wrapper = createExcerpt({ onToggleCollapsed }, TALL_DIV); + const control = wrapper.find('[data-testid="excerpt-expand"]'); + assert.equal(getExcerptHeight(wrapper), 40); + control.simulate('click'); + assert.called(onToggleCollapsed); + }); + + context('when inline controls are enabled', () => { + const getToggleButton = wrapper => + wrapper.find( + 'LinkButton[title="Toggle visibility of full excerpt text"]', + ); + + it('displays inline controls if collapsed', () => { + const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV); + assert.isTrue(wrapper.exists('InlineControls')); + }); + + it('does not display inline controls if not collapsed', () => { + const wrapper = createExcerpt({ inlineControls: true }, SHORT_DIV); + assert.isFalse(wrapper.exists('InlineControls')); + }); + + it('toggles the expanded state when clicked', () => { + const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV); + const button = getToggleButton(wrapper); + assert.equal(getExcerptHeight(wrapper), 40); + act(() => { + button.props().onClick(); + }); + wrapper.update(); + assert.equal(getExcerptHeight(wrapper), 200); + }); + + it("sets button's default state to un-expanded", () => { + const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV); + const button = getToggleButton(wrapper); + assert.equal(button.prop('expanded'), false); + assert.equal(button.text(), 'More'); + }); + + it("changes button's state to expanded when clicked", () => { + const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV); + let button = getToggleButton(wrapper); + act(() => { + button.props().onClick(); + }); + wrapper.update(); + button = getToggleButton(wrapper); + assert.equal(button.prop('expanded'), true); + assert.equal(button.text(), 'Less'); + }); + }); + + it( + 'should pass a11y checks', + checkAccessibility([ + { + name: 'external controls', + content: () => createExcerpt({}, TALL_DIV), + }, + { + name: 'internal controls', + content: () => createExcerpt({ inlineControls: true }, TALL_DIV), + }, + ]), + ); +}); diff --git a/src/index.ts b/src/index.ts index a9bfeb8..73adc08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { AnnotationShareControl, AnnotationTimestamps, AnnotationUser, + Excerpt, MarkdownView, MentionPopoverContent, ModerationStatusSelect, @@ -22,6 +23,7 @@ export type { AnnotationShareControlProps, AnnotationTimestampsProps, AnnotationUserProps, + ExcerptProps, MarkdownViewProps, MentionPopoverContentProps, ModerationStatusSelectProps, diff --git a/src/utils/observe-element-size.ts b/src/utils/observe-element-size.ts new file mode 100644 index 0000000..d666fe7 --- /dev/null +++ b/src/utils/observe-element-size.ts @@ -0,0 +1,21 @@ +/** + * Watch for changes in the size (`clientWidth` and `clientHeight`) of + * an element. + * + * Returns a cleanup function which should be called to remove observers when + * updates are no longer needed. + * + * @param element - HTML element to watch + * @param onSizeChanged - Callback to invoke with the `clientWidth` and + * `clientHeight` of the element when a change in its size is detected. + */ +export function observeElementSize( + element: Element, + onSizeChanged: (width: number, height: number) => void, +): () => void { + const observer = new ResizeObserver(() => + onSizeChanged(element.clientWidth, element.clientHeight), + ); + observer.observe(element); + return () => observer.disconnect(); +} diff --git a/src/utils/test/observe-element-size-test.js b/src/utils/test/observe-element-size-test.js new file mode 100644 index 0000000..978aae5 --- /dev/null +++ b/src/utils/test/observe-element-size-test.js @@ -0,0 +1,50 @@ +import { waitFor } from '@hypothesis/frontend-testing'; + +import { observeElementSize } from '../observe-element-size'; + +/** + * Give MutationObserver, ResizeObserver etc. a chance to deliver their + * notifications. + * + * This waits for a fixed amount of time. If you can wait for a specific event + * using `waitFor`, you should do so. + */ +function waitForObservations() { + return new Promise(resolve => setTimeout(resolve, 1)); +} + +describe('observeElementSize', () => { + let content; + let sizeChanged; + let stopObserving; + + beforeEach(() => { + sizeChanged = sinon.stub(); + content = document.createElement('div'); + content.innerHTML = '

Some test content

'; + document.body.appendChild(content); + }); + + afterEach(() => { + stopObserving(); + content.remove(); + }); + + function startObserving() { + stopObserving = observeElementSize(content, sizeChanged); + } + + it('notifies when the element size changes', async () => { + startObserving(); + + content.innerHTML = '

different content

'; + await waitFor(() => sizeChanged.called); + + stopObserving(); + sizeChanged.reset(); + + content.innerHTML = '

other content

'; + await waitForObservations(); + assert.notCalled(sizeChanged); + }); +});