diff --git a/src/components/Excerpt.tsx b/src/components/Excerpt.tsx
index 660b4ad..cc70ae0 100644
--- a/src/components/Excerpt.tsx
+++ b/src/components/Excerpt.tsx
@@ -5,21 +5,48 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { observeElementSize } from '../utils/observe-element-size';
-type InlineControlsProps = {
+type InlineControlExcerptProps = {
+ /**
+ * The excerpt provides internal controls to expand and collapse
+ * the content.
+ */
+ inlineControl: true;
+
+ /**
+ * Text on inline control when clicking it will expand the content.
+ * Defaults to 'More'.
+ */
+ inlineControlExpandText?: string;
+
+ /**
+ * Text on inline control when clicking it will collapse the content.
+ * Defaults to 'Less'.
+ */
+ inlineControlCollapseText?: string;
+
+ /** Additional styles to pass to the inline controls element. */
+ inlineControlStyle?: JSX.CSSProperties;
+ /** Additional CSS classes to pass to the inline controls element. */
+ inlineControlClasses?: string | string[];
+};
+
+type InlineControlProps = InlineControlExcerptProps & {
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({
+function InlineControl({
isCollapsed,
setCollapsed,
- linkStyle,
-}: InlineControlsProps) {
+ inlineControlExpandText = 'More',
+ inlineControlCollapseText = 'Less',
+ inlineControlStyle,
+ inlineControlClasses,
+}: InlineControlProps) {
return (
setCollapsed(!isCollapsed)}
expanded={!isCollapsed}
title="Toggle visibility of full excerpt text"
- style={linkStyle}
+ style={inlineControlStyle}
+ classes={inlineControlClasses}
underline="always"
inline
>
- {isCollapsed ? 'More' : 'Less'}
+ {isCollapsed ? inlineControlExpandText : inlineControlCollapseText}
);
}
-const noop = () => {};
-
-export type ExcerptProps = {
- children?: ComponentChildren;
-
+type ExternalControlExcerptProps = {
/**
- * 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.
+ * The caller is responsible for providing their own collapse/expand control,
+ * in combination with `collapsed` and `onToggleCollapsed` props.
*/
- inlineControls?: boolean;
+ inlineControl: false;
+};
- /**
- * If the content should be truncated if its height exceeds
- * `collapsedHeight + overflowThreshold`. This prop is only used if
- * `inlineControls` is false.
- */
- collapse?: boolean;
+export type ExcerptProps = {
+ children?: ComponentChildren;
- /**
- * Maximum height of the container, in pixels, when it is collapsed.
- */
+ /** Maximum height of the container, in pixels, when it is collapsed. */
collapsedHeight: number;
/**
@@ -85,45 +102,78 @@ export type ExcerptProps = {
overflowThreshold?: number;
/**
- * Called when the content height exceeds or falls below
+ * Whether a shadow is drawn at the bottom of collapsed content, to hint that
+ * content is being hidden.
+ *
+ * The shadow area can be clicked to expand the container so the content is
+ * fully visible.
+ *
+ * Defaults to `false` for excerpts with inline control, and `true` for
+ * excerpts with external control.
+ */
+ shadow?: boolean;
+
+ /**
+ * If the content should be truncated if its height exceeds
* `collapsedHeight + overflowThreshold`.
+ *
+ * Use this prop in combination with `onToggleCollapsed` to make this excerpt
+ * a controlled component.
+ *
+ * Defaults to `true`.
*/
- onCollapsibleChanged?: (isCollapsible: boolean) => void;
+ collapsed?: boolean;
/**
- * 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.
+ * If this function is provided, it is called when the user requests to expand
+ * the content by clicking the shadowed zone at the bottom of the container
+ * (if `shadow` is `true`) or the inline control (if `inlineControl` is `true`)
*/
onToggleCollapsed?: (collapsed: boolean) => void;
/**
- * Additional styles to pass to the inline controls element.
- * Ignored if inlineControls is `false`.
+ * Called when the content height exceeds or falls below
+ * `collapsedHeight + overflowThreshold`.
*/
- inlineControlsLinkStyle?: JSX.CSSProperties;
-};
+ onCollapsibleChanged?: (isCollapsible: boolean) => void;
+} & (InlineControlExcerptProps | ExternalControlExcerptProps);
/**
* 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.
+ * controls (if `inlineControls` is `true`) or by the caller using a custom
+ * control.
*/
export default function Excerpt({
children,
- collapse = false,
+ collapsed,
collapsedHeight,
- inlineControls = true,
- onCollapsibleChanged = noop,
- onToggleCollapsed = noop,
+ onCollapsibleChanged,
+ onToggleCollapsed,
overflowThreshold = 0,
- inlineControlsLinkStyle = {},
+ shadow,
+ ...rest
}: ExcerptProps) {
- const [collapsedByInlineControls, setCollapsedByInlineControls] =
- useState(true);
+ // If `collapsed` is present, treat this as a controlled component
+ const isControlled = typeof collapsed === 'boolean';
+ // Only use this local state if Excerpt is uncontrolled
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(
+ collapsed ?? true,
+ );
+ const isCollapsed = isControlled ? collapsed : uncontrolledCollapsed;
+ const setCollapsed = useCallback(
+ (collapsed: boolean) => {
+ onToggleCollapsed?.(collapsed);
+ if (!isControlled) {
+ setUncontrolledCollapsed(collapsed);
+ }
+ },
+ [isControlled, onToggleCollapsed],
+ );
+ const inlineControl = rest.inlineControl;
+ const withShadow = shadow ?? !inlineControl;
const contentElement = useRef(null);
// Measured height of `contentElement` in pixels
@@ -138,7 +188,7 @@ export default function Excerpt({
// prettier-ignore
const isCollapsible =
newContentHeight > (collapsedHeight + overflowThreshold);
- onCollapsibleChanged(isCollapsible);
+ onCollapsibleChanged?.(isCollapsible);
}, [collapsedHeight, onCollapsibleChanged, overflowThreshold]);
useLayoutEffect(() => {
@@ -154,19 +204,13 @@ export default function Excerpt({
// expanding/collapsing the content.
// prettier-ignore
const isOverflowing = contentHeight > (collapsedHeight + overflowThreshold);
- const isCollapsed = inlineControls ? collapsedByInlineControls : collapse;
const isExpandable = isOverflowing && isCollapsed;
- const contentStyle: Record = {};
+ const contentStyle: JSX.CSSProperties = {};
if (contentHeight !== 0) {
- contentStyle['max-height'] = isExpandable ? collapsedHeight : contentHeight;
+ contentStyle.maxHeight = isExpandable ? collapsedHeight : contentHeight;
}
- const setCollapsed = (collapsed: boolean) =>
- inlineControls
- ? setCollapsedByInlineControls(collapsed)
- : onToggleCollapsed(collapsed);
-
return (
- {isOverflowing && inlineControls && (
-
)}
diff --git a/src/components/test/Excerpt-test.js b/src/components/test/Excerpt-test.js
index ca9b2a8..170605d 100644
--- a/src/components/test/Excerpt-test.js
+++ b/src/components/test/Excerpt-test.js
@@ -1,6 +1,7 @@
import { checkAccessibility } from '@hypothesis/frontend-testing';
import { mount } from '@hypothesis/frontend-testing';
import { act } from 'preact/test-utils';
+import sinon from 'sinon';
import Excerpt, { $imports } from '../Excerpt';
@@ -20,7 +21,7 @@ describe('Excerpt', () => {
{content}
@@ -43,11 +44,13 @@ describe('Excerpt', () => {
});
function getExcerptHeight(wrapper) {
- return wrapper.find('[data-testid="excerpt-container"]').prop('style')[
- 'max-height'
- ];
+ return wrapper.find('[data-testid="excerpt-container"]').prop('style')
+ .maxHeight;
}
+ const getToggleButton = wrapper =>
+ wrapper.find('LinkButton[title="Toggle visibility of full excerpt text"]');
+
it('renders content in container', () => {
const wrapper = createExcerpt();
const contentEl = wrapper.find('[data-testid="excerpt-content"]');
@@ -101,7 +104,7 @@ describe('Excerpt', () => {
assert.calledWith(onCollapsibleChanged, true);
});
- it('calls `onToggleCollapsed` when user clicks in bottom area to expand excerpt', () => {
+ it('calls `onToggleCollapsed` when user clicks in bottom shadow to expand excerpt', () => {
const onToggleCollapsed = sinon.stub();
const wrapper = createExcerpt({ onToggleCollapsed }, TALL_DIV);
const control = wrapper.find('[data-testid="excerpt-expand"]');
@@ -111,23 +114,18 @@ describe('Excerpt', () => {
});
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'));
+ const wrapper = createExcerpt({ inlineControl: true }, TALL_DIV);
+ assert.isTrue(wrapper.exists('InlineControl'));
});
it('does not display inline controls if not collapsed', () => {
- const wrapper = createExcerpt({ inlineControls: true }, SHORT_DIV);
- assert.isFalse(wrapper.exists('InlineControls'));
+ const wrapper = createExcerpt({ inlineControl: true }, SHORT_DIV);
+ assert.isFalse(wrapper.exists('InlineControl'));
});
it('toggles the expanded state when clicked', () => {
- const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
+ const wrapper = createExcerpt({ inlineControl: true }, TALL_DIV);
const button = getToggleButton(wrapper);
assert.equal(getExcerptHeight(wrapper), 40);
act(() => {
@@ -137,23 +135,82 @@ describe('Excerpt', () => {
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');
+ [undefined, 'Show more'].forEach(inlineControlExpandText => {
+ it("sets button's default state to un-expanded", () => {
+ const wrapper = createExcerpt(
+ { inlineControl: true, inlineControlExpandText },
+ TALL_DIV,
+ );
+ const button = getToggleButton(wrapper);
+ assert.equal(button.prop('expanded'), false);
+ assert.equal(button.text(), inlineControlExpandText ?? '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();
+ [undefined, 'Show less'].forEach(inlineControlCollapseText => {
+ it("changes button's state to expanded when clicked", () => {
+ const wrapper = createExcerpt(
+ { inlineControl: true, inlineControlCollapseText },
+ 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(), inlineControlCollapseText ?? 'Less');
});
- wrapper.update();
- button = getToggleButton(wrapper);
- assert.equal(button.prop('expanded'), true);
- assert.equal(button.text(), 'Less');
+ });
+ });
+
+ context('when excerpt is externally controlled', () => {
+ [true, false].forEach(collapsed => {
+ it('calls onToggleCollapsed when toggled via inline control', () => {
+ const fakeOnToggleCollapsed = sinon.stub();
+ const wrapper = createExcerpt(
+ {
+ inlineControl: true,
+ collapsed,
+ onToggleCollapsed: fakeOnToggleCollapsed,
+ },
+ TALL_DIV,
+ );
+
+ getToggleButton(wrapper).props().onClick();
+
+ assert.calledWith(fakeOnToggleCollapsed, !collapsed);
+ });
+ });
+
+ it('calls onToggleCollapsed when toggled via shadow', () => {
+ const fakeOnToggleCollapsed = sinon.stub();
+ const wrapper = createExcerpt(
+ {
+ shadow: true,
+ collapsed: true,
+ onToggleCollapsed: fakeOnToggleCollapsed,
+ },
+ TALL_DIV,
+ );
+
+ wrapper.find('[data-testid="excerpt-expand"]').props().onClick();
+
+ assert.calledWith(fakeOnToggleCollapsed, false);
+ });
+
+ it('updates collapsed state when toggled externally', () => {
+ const wrapper = createExcerpt(
+ { inlineControl: true, collapsed: true },
+ TALL_DIV,
+ );
+ const isCollapsed = () =>
+ wrapper.find('InlineControl').prop('isCollapsed');
+
+ assert.isTrue(isCollapsed());
+ wrapper.setProps({ collapsed: false });
+ assert.isFalse(isCollapsed());
});
});
@@ -166,7 +223,7 @@ describe('Excerpt', () => {
},
{
name: 'internal controls',
- content: () => createExcerpt({ inlineControls: true }, TALL_DIV),
+ content: () => createExcerpt({ inlineControl: true }, TALL_DIV),
},
]),
);