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), }, ]), );