Skip to content

Commit bc215d8

Browse files
committed
Make Excerpt more predictable to work with
1 parent 014fcad commit bc215d8

File tree

2 files changed

+149
-80
lines changed

2 files changed

+149
-80
lines changed

src/components/Excerpt.tsx

Lines changed: 98 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,48 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks';
55

66
import { observeElementSize } from '../utils/observe-element-size';
77

8-
type InlineControlsProps = {
8+
type InlineControlExcerptProps = {
9+
/**
10+
* The excerpt provides internal controls to expand and collapse
11+
* the content.
12+
*/
13+
inlineControl: true;
14+
15+
/**
16+
* Text on inline control when clicking it will expand the content.
17+
* Defaults to 'More'.
18+
*/
19+
inlineControlExpandText?: string;
20+
21+
/**
22+
* Text on inline control when clicking it will collapse the content.
23+
* Defaults to 'Less'.
24+
*/
25+
inlineControlCollapseText?: string;
26+
27+
/** Additional styles to pass to the inline controls element. */
28+
inlineControlStyle?: JSX.CSSProperties;
29+
/** Additional CSS classes to pass to the inline controls element. */
30+
inlineControlClasses?: string | string[];
31+
};
32+
33+
type InlineControlProps = InlineControlExcerptProps & {
934
isCollapsed: boolean;
1035
setCollapsed: (collapsed: boolean) => void;
11-
linkStyle: JSX.CSSProperties;
1236
};
1337

1438
/**
1539
* An optional toggle link at the bottom of an excerpt which controls whether
1640
* it is expanded or collapsed.
1741
*/
18-
function InlineControls({
42+
function InlineControl({
1943
isCollapsed,
2044
setCollapsed,
21-
linkStyle,
22-
}: InlineControlsProps) {
45+
inlineControlExpandText = 'More',
46+
inlineControlCollapseText = 'Less',
47+
inlineControlStyle,
48+
inlineControlClasses,
49+
}: InlineControlProps) {
2350
return (
2451
<div
2552
className={classnames(
@@ -42,40 +69,30 @@ function InlineControls({
4269
onClick={() => setCollapsed(!isCollapsed)}
4370
expanded={!isCollapsed}
4471
title="Toggle visibility of full excerpt text"
45-
style={linkStyle}
72+
style={inlineControlStyle}
73+
classes={inlineControlClasses}
4674
underline="always"
4775
inline
4876
>
49-
{isCollapsed ? 'More' : 'Less'}
77+
{isCollapsed ? inlineControlExpandText : inlineControlCollapseText}
5078
</LinkButton>
5179
</div>
5280
</div>
5381
);
5482
}
5583

56-
const noop = () => {};
57-
58-
export type ExcerptProps = {
59-
children?: ComponentChildren;
60-
84+
type ExternalControlExcerptProps = {
6185
/**
62-
* If `true`, the excerpt provides internal controls to expand and collapse
63-
* the content. If `false`, the caller sets the collapsed state via the
64-
* `collapse` prop. When using inline controls, the excerpt is initially
65-
* collapsed.
86+
* The caller is responsible for providing their own collapse/expand control,
87+
* in combination with `collapsed` and `onToggleCollapsed` props.
6688
*/
67-
inlineControls?: boolean;
89+
inlineControl: false;
90+
};
6891

69-
/**
70-
* If the content should be truncated if its height exceeds
71-
* `collapsedHeight + overflowThreshold`. This prop is only used if
72-
* `inlineControls` is false.
73-
*/
74-
collapse?: boolean;
92+
export type ExcerptProps = {
93+
children?: ComponentChildren;
7594

76-
/**
77-
* Maximum height of the container, in pixels, when it is collapsed.
78-
*/
95+
/** Maximum height of the container, in pixels, when it is collapsed. */
7996
collapsedHeight: number;
8097

8198
/**
@@ -85,44 +102,72 @@ export type ExcerptProps = {
85102
overflowThreshold?: number;
86103

87104
/**
88-
* Called when the content height exceeds or falls below
105+
* Whether a shadow is drawn at the bottom of collapsed content, to hint that
106+
* content is being hidden.
107+
*
108+
* The shadow area can be clicked to expand the container so the content is
109+
* fully visible.
110+
*
111+
* Defaults to `false` for excerpts with inline control, and `true` for
112+
* excerpts with external control.
113+
*/
114+
shadow?: boolean;
115+
116+
/**
117+
* If the content should be truncated if its height exceeds
89118
* `collapsedHeight + overflowThreshold`.
119+
*
120+
* Use this prop in combination with `onToggleCollapsed` to make this excerpt
121+
* a controlled component.
122+
*
123+
* Defaults to `true`.
90124
*/
91-
onCollapsibleChanged?: (isCollapsible: boolean) => void;
125+
collapsed?: boolean;
92126

93127
/**
94-
* When `inlineControls` is `false`, this function is called when the user
95-
* requests to expand the content by clicking a zone at the bottom of the
96-
* container.
128+
* If this function is provided, it is called when the user requests to expand
129+
* the content by clicking the shadowed zone at the bottom of the container
130+
* (if `shadow` is `true`) or the inline control (if `inlineControl` is `true`)
97131
*/
98132
onToggleCollapsed?: (collapsed: boolean) => void;
99133

100134
/**
101-
* Additional styles to pass to the inline controls element.
102-
* Ignored if inlineControls is `false`.
135+
* Called when the content height exceeds or falls below
136+
* `collapsedHeight + overflowThreshold`.
103137
*/
104-
inlineControlsLinkStyle?: JSX.CSSProperties;
105-
};
138+
onCollapsibleChanged?: (isCollapsible: boolean) => void;
139+
} & (InlineControlExcerptProps | ExternalControlExcerptProps);
106140

107141
/**
108142
* A container which truncates its content when they exceed a specified height.
109143
*
110144
* The collapsed state of the container can be handled either via internal
111-
* controls (if `inlineControls` is `true`) or by the caller using the
112-
* `collapse` prop.
145+
* controls (if `inlineControls` is `true`) or by the caller using a custom
146+
* control.
113147
*/
114148
export default function Excerpt({
115149
children,
116-
collapse = false,
150+
collapsed = true,
117151
collapsedHeight,
118-
inlineControls = true,
119-
onCollapsibleChanged = noop,
120-
onToggleCollapsed = noop,
152+
onCollapsibleChanged,
153+
onToggleCollapsed,
121154
overflowThreshold = 0,
122-
inlineControlsLinkStyle = {},
155+
shadow,
156+
...rest
123157
}: ExcerptProps) {
158+
const inlineControl = rest.inlineControl;
159+
const withShadow = shadow ?? !inlineControl;
124160
const [collapsedByInlineControls, setCollapsedByInlineControls] =
125-
useState(true);
161+
useState(collapsed);
162+
const setCollapsed = useCallback(
163+
(collapsed: boolean) => {
164+
if (inlineControl) {
165+
setCollapsedByInlineControls(collapsed);
166+
}
167+
onToggleCollapsed?.(collapsed);
168+
},
169+
[inlineControl, onToggleCollapsed],
170+
);
126171

127172
const contentElement = useRef<HTMLDivElement | null>(null);
128173

@@ -138,7 +183,7 @@ export default function Excerpt({
138183
// prettier-ignore
139184
const isCollapsible =
140185
newContentHeight > (collapsedHeight + overflowThreshold);
141-
onCollapsibleChanged(isCollapsible);
186+
onCollapsibleChanged?.(isCollapsible);
142187
}, [collapsedHeight, onCollapsibleChanged, overflowThreshold]);
143188

144189
useLayoutEffect(() => {
@@ -154,19 +199,14 @@ export default function Excerpt({
154199
// expanding/collapsing the content.
155200
// prettier-ignore
156201
const isOverflowing = contentHeight > (collapsedHeight + overflowThreshold);
157-
const isCollapsed = inlineControls ? collapsedByInlineControls : collapse;
202+
const isCollapsed = inlineControl ? collapsedByInlineControls : collapsed;
158203
const isExpandable = isOverflowing && isCollapsed;
159204

160205
const contentStyle: Record<string, number> = {};
161206
if (contentHeight !== 0) {
162207
contentStyle['max-height'] = isExpandable ? collapsedHeight : contentHeight;
163208
}
164209

165-
const setCollapsed = (collapsed: boolean) =>
166-
inlineControls
167-
? setCollapsedByInlineControls(collapsed)
168-
: onToggleCollapsed(collapsed);
169-
170210
return (
171211
<div
172212
data-testid="excerpt-container"
@@ -200,23 +240,23 @@ export default function Excerpt({
200240
'transition-[opacity] duration-150 ease-linear',
201241
'absolute w-full bottom-0 h-touch-minimum',
202242
{
203-
// For expandable excerpts not using inlineControls, style this
204-
// element with a shadow-like gradient
243+
// For expandable excerpts with shadow, style this element with a
244+
// shadow-like gradient
205245
'bg-gradient-to-b from-white/0 via-95% via-black/10 to-100% to-black/15':
206-
!inlineControls && isExpandable,
207-
'bg-none': inlineControls,
208-
// Don't make this shadow visible OR clickable if there's nothing
246+
withShadow && isExpandable,
247+
'bg-none': !withShadow,
248+
// Don't make the shadow visible OR clickable if there's nothing
209249
// to do here (the excerpt isn't expandable)
210250
'opacity-0 pointer-events-none': !isExpandable,
211251
},
212252
)}
213253
title="Show the full excerpt"
214254
/>
215-
{isOverflowing && inlineControls && (
216-
<InlineControls
255+
{isOverflowing && inlineControl && (
256+
<InlineControl
217257
isCollapsed={collapsedByInlineControls}
218258
setCollapsed={setCollapsed}
219-
linkStyle={inlineControlsLinkStyle}
259+
{...rest}
220260
/>
221261
)}
222262
</div>

src/components/test/Excerpt-test.js

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { checkAccessibility } from '@hypothesis/frontend-testing';
22
import { mount } from '@hypothesis/frontend-testing';
33
import { act } from 'preact/test-utils';
4+
import sinon from 'sinon';
45

56
import Excerpt, { $imports } from '../Excerpt';
67

@@ -20,7 +21,7 @@ describe('Excerpt', () => {
2021
<Excerpt
2122
collapse={true}
2223
collapsedHeight={40}
23-
inlineControls={false}
24+
inlineControl={false}
2425
{...props}
2526
>
2627
{content}
@@ -101,7 +102,7 @@ describe('Excerpt', () => {
101102
assert.calledWith(onCollapsibleChanged, true);
102103
});
103104

104-
it('calls `onToggleCollapsed` when user clicks in bottom area to expand excerpt', () => {
105+
it('calls `onToggleCollapsed` when user clicks in bottom shadow to expand excerpt', () => {
105106
const onToggleCollapsed = sinon.stub();
106107
const wrapper = createExcerpt({ onToggleCollapsed }, TALL_DIV);
107108
const control = wrapper.find('[data-testid="excerpt-expand"]');
@@ -117,17 +118,17 @@ describe('Excerpt', () => {
117118
);
118119

119120
it('displays inline controls if collapsed', () => {
120-
const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
121-
assert.isTrue(wrapper.exists('InlineControls'));
121+
const wrapper = createExcerpt({ inlineControl: true }, TALL_DIV);
122+
assert.isTrue(wrapper.exists('InlineControl'));
122123
});
123124

124125
it('does not display inline controls if not collapsed', () => {
125-
const wrapper = createExcerpt({ inlineControls: true }, SHORT_DIV);
126-
assert.isFalse(wrapper.exists('InlineControls'));
126+
const wrapper = createExcerpt({ inlineControl: true }, SHORT_DIV);
127+
assert.isFalse(wrapper.exists('InlineControl'));
127128
});
128129

129130
it('toggles the expanded state when clicked', () => {
130-
const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
131+
const wrapper = createExcerpt({ inlineControl: true }, TALL_DIV);
131132
const button = getToggleButton(wrapper);
132133
assert.equal(getExcerptHeight(wrapper), 40);
133134
act(() => {
@@ -137,23 +138,51 @@ describe('Excerpt', () => {
137138
assert.equal(getExcerptHeight(wrapper), 200);
138139
});
139140

140-
it("sets button's default state to un-expanded", () => {
141-
const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
142-
const button = getToggleButton(wrapper);
143-
assert.equal(button.prop('expanded'), false);
144-
assert.equal(button.text(), 'More');
141+
[undefined, 'Show more'].forEach(inlineControlExpandText => {
142+
it("sets button's default state to un-expanded", () => {
143+
const wrapper = createExcerpt(
144+
{ inlineControl: true, inlineControlExpandText },
145+
TALL_DIV,
146+
);
147+
const button = getToggleButton(wrapper);
148+
assert.equal(button.prop('expanded'), false);
149+
assert.equal(button.text(), inlineControlExpandText ?? 'More');
150+
});
145151
});
146152

147-
it("changes button's state to expanded when clicked", () => {
148-
const wrapper = createExcerpt({ inlineControls: true }, TALL_DIV);
149-
let button = getToggleButton(wrapper);
150-
act(() => {
151-
button.props().onClick();
153+
[undefined, 'Show less'].forEach(inlineControlCollapseText => {
154+
it("changes button's state to expanded when clicked", () => {
155+
const wrapper = createExcerpt(
156+
{ inlineControl: true, inlineControlCollapseText },
157+
TALL_DIV,
158+
);
159+
let button = getToggleButton(wrapper);
160+
act(() => {
161+
button.props().onClick();
162+
});
163+
wrapper.update();
164+
button = getToggleButton(wrapper);
165+
assert.equal(button.prop('expanded'), true);
166+
assert.equal(button.text(), inlineControlCollapseText ?? 'Less');
167+
});
168+
});
169+
170+
[true, false].forEach(collapsed => {
171+
it('allows to control Excerpt externally', () => {
172+
const fakeOnToggleCollapsed = sinon.stub();
173+
const wrapper = createExcerpt(
174+
{
175+
inlineControl: true,
176+
collapsed,
177+
onToggleCollapsed: fakeOnToggleCollapsed,
178+
},
179+
TALL_DIV,
180+
);
181+
182+
getToggleButton(wrapper).props().onClick();
183+
184+
assert.calledWith(fakeOnToggleCollapsed, !collapsed);
152185
});
153-
wrapper.update();
154-
button = getToggleButton(wrapper);
155-
assert.equal(button.prop('expanded'), true);
156-
assert.equal(button.text(), 'Less');
157186
});
158187
});
159188

@@ -166,7 +195,7 @@ describe('Excerpt', () => {
166195
},
167196
{
168197
name: 'internal controls',
169-
content: () => createExcerpt({ inlineControls: true }, TALL_DIV),
198+
content: () => createExcerpt({ inlineControl: true }, TALL_DIV),
170199
},
171200
]),
172201
);

0 commit comments

Comments
 (0)