Skip to content

Commit 13ea749

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

File tree

2 files changed

+193
-92
lines changed

2 files changed

+193
-92
lines changed

src/components/Excerpt.tsx

Lines changed: 106 additions & 62 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,45 +102,78 @@ 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,
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) {
124-
const [collapsedByInlineControls, setCollapsedByInlineControls] =
125-
useState(true);
158+
// If `collapsed` is present, treat this as a controlled component
159+
const isControlled = typeof collapsed === 'boolean';
160+
// Only use this local state if Excerpt is uncontrolled
161+
const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(
162+
collapsed ?? true,
163+
);
164+
const isCollapsed = isControlled ? collapsed : uncontrolledCollapsed;
165+
const setCollapsed = useCallback(
166+
(collapsed: boolean) => {
167+
onToggleCollapsed?.(collapsed);
168+
if (!isControlled) {
169+
setUncontrolledCollapsed(collapsed);
170+
}
171+
},
172+
[isControlled, onToggleCollapsed],
173+
);
126174

175+
const inlineControl = rest.inlineControl;
176+
const withShadow = shadow ?? !inlineControl;
127177
const contentElement = useRef<HTMLDivElement | null>(null);
128178

129179
// Measured height of `contentElement` in pixels
@@ -138,7 +188,7 @@ export default function Excerpt({
138188
// prettier-ignore
139189
const isCollapsible =
140190
newContentHeight > (collapsedHeight + overflowThreshold);
141-
onCollapsibleChanged(isCollapsible);
191+
onCollapsibleChanged?.(isCollapsible);
142192
}, [collapsedHeight, onCollapsibleChanged, overflowThreshold]);
143193

144194
useLayoutEffect(() => {
@@ -154,19 +204,13 @@ export default function Excerpt({
154204
// expanding/collapsing the content.
155205
// prettier-ignore
156206
const isOverflowing = contentHeight > (collapsedHeight + overflowThreshold);
157-
const isCollapsed = inlineControls ? collapsedByInlineControls : collapse;
158207
const isExpandable = isOverflowing && isCollapsed;
159208

160-
const contentStyle: Record<string, number> = {};
209+
const contentStyle: JSX.CSSProperties = {};
161210
if (contentHeight !== 0) {
162-
contentStyle['max-height'] = isExpandable ? collapsedHeight : contentHeight;
211+
contentStyle.maxHeight = isExpandable ? collapsedHeight : contentHeight;
163212
}
164213

165-
const setCollapsed = (collapsed: boolean) =>
166-
inlineControls
167-
? setCollapsedByInlineControls(collapsed)
168-
: onToggleCollapsed(collapsed);
169-
170214
return (
171215
<div
172216
data-testid="excerpt-container"
@@ -200,23 +244,23 @@ export default function Excerpt({
200244
'transition-[opacity] duration-150 ease-linear',
201245
'absolute w-full bottom-0 h-touch-minimum',
202246
{
203-
// For expandable excerpts not using inlineControls, style this
204-
// element with a shadow-like gradient
247+
// For expandable excerpts with shadow, style this element with a
248+
// shadow-like gradient
205249
'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
250+
withShadow && isExpandable,
251+
'bg-none': !withShadow,
252+
// Don't make the shadow visible OR clickable if there's nothing
209253
// to do here (the excerpt isn't expandable)
210254
'opacity-0 pointer-events-none': !isExpandable,
211255
},
212256
)}
213257
title="Show the full excerpt"
214258
/>
215-
{isOverflowing && inlineControls && (
216-
<InlineControls
217-
isCollapsed={collapsedByInlineControls}
259+
{isOverflowing && inlineControl && (
260+
<InlineControl
261+
isCollapsed={isCollapsed}
218262
setCollapsed={setCollapsed}
219-
linkStyle={inlineControlsLinkStyle}
263+
{...rest}
220264
/>
221265
)}
222266
</div>

0 commit comments

Comments
 (0)