+
+ {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);
+ });
+});