Skip to content

Commit

Permalink
Merge pull request #1158 from appfolio/collapsable-any-children
Browse files Browse the repository at this point in the history
CollapsableText: Allow for any children, move show more button to bottom
  • Loading branch information
JeremyRH authored May 9, 2023
2 parents 8fea247 + ef444a3 commit 1820016
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 92 deletions.
15 changes: 15 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@
"no-confusing-arrow": "off",
"no-only-tests/no-only-tests": "error",
"no-nested-ternary": "off",
"no-restricted-syntax": [
"error",
{
"selector": "ForInStatement",
"message": "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array."
},
{
"selector": "LabeledStatement",
"message": "Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand."
},
{
"selector": "WithStatement",
"message": "`with` is disallowed in strict mode because it makes code impossible to predict and optimize."
}
],
"react/default-props-match-prop-types": ["error", { "allowRequiredDefaults": true }],
"react/no-array-index-key": "error",
"react/no-find-dom-node": "error",
Expand Down
102 changes: 52 additions & 50 deletions src/components/CollapsableText/CollapsableText.spec.js
Original file line number Diff line number Diff line change
@@ -1,72 +1,74 @@
import assert from 'assert';
import { mount } from 'enzyme';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import Button from '../Button/Button';
import CollapsableText from './CollapsableText';

const mockElement = {
clientHeight: 1,
scrollHeight: 2,
};

jest.mock('../../hooks/useIntervalRef', () => {
return {
useIntervalRef: jest.fn((cb) => () => cb(mockElement)),
};
});

describe('<CollapsableText />', () => {
it('should show whole string without button if string length is smaller than maxLength', () => {
const component = mount(<CollapsableText>Hello World</CollapsableText>);
assert.equal(component.text(), 'Hello World');
beforeEach(() => {
mockElement.scrollHeight = 2;
});

it('should shorten the string and show button if string length is greater than maxLength', () => {
const component = mount(<CollapsableText maxLength={5}>Hello World</CollapsableText>);
assert.equal(component.text(), 'Hello… Show More');
it('should show the whole string without button if line length is less than maxLines', () => {
mockElement.scrollHeight = 1;
render(<CollapsableText maxLines={1}>Hello World</CollapsableText>);
expect(screen.getByText('Hello World')).toBeInTheDocument();
expect(screen.queryByText('Show More')).toBeNull();
});

it('should show whole string and show button if string length is greater than maxLength and default is to show all', () => {
const component = mount(
<CollapsableText maxLength={5} collapsed={false}>
Hello World
it('should limit the container style.maxHeight and show a "Show More" button', () => {
render(
<CollapsableText maxLines={1}>
Hello World <br /> Wow
</CollapsableText>
);
assert.equal(component.text(), 'Hello World Show Less');
expect(screen.getByText(/Hello World/).style.maxHeight).toBeTruthy();
expect(screen.getByText('Show More')).toBeInTheDocument();
});

it('toggle show whole or part of string after clicking button', () => {
const component = mount(<CollapsableText maxLength={5}>Hello World</CollapsableText>);
assert.equal(component.text(), 'Hello… Show More');

let button = component.find(Button);
button.simulate('click');
assert.equal(component.text(), 'Hello World Show Less');

button = component.find(Button);
button.simulate('click');
assert.equal(component.text(), 'Hello… Show More');
});

it('should toggle after prop change', () => {
const component = mount(<CollapsableText maxLength={5}>Hello World</CollapsableText>);
assert.equal(component.text(), 'Hello… Show More');

component.setProps({ collapsed: false });
component.update();
assert.equal(component.text(), 'Hello World Show Less');

component.setProps({ collapsed: true });
component.update();
assert.equal(component.text(), 'Hello… Show More');
it('should remove style.maxHeight when "Show More" button is clicked', async () => {
render(
<CollapsableText maxLines={1}>
Hello World <br /> Wow
</CollapsableText>
);
await userEvent.click(screen.getByText('Show More'));
expect(await screen.findByText('Show Less')).toBeInTheDocument();
expect(screen.getByText(/Hello World/).style.maxHeight).toBe('');
});

it('should respect overwritten value of moreLabel', () => {
const component = mount(
<CollapsableText maxLength={5} moreLabel="Gimme more">
Hello World
it('should add style.maxHeight when "Show Less" button is clicked', async () => {
render(
<CollapsableText collapsed={false} maxLines={1}>
Hello World <br /> Wow
</CollapsableText>
);
assert.equal(component.text(), 'Hello… Gimme more');
await userEvent.click(screen.getByText('Show Less'));
expect(await screen.findByText('Show More')).toBeInTheDocument();
expect(screen.getByText(/Hello World/).style.maxHeight).toBeTruthy();
});

it('should respect overwritten value of lessLabel', () => {
const component = mount(
<CollapsableText maxLength={5} lessLabel="Hide it from me">
Hello World
it('should allow custom button labels', async () => {
render(
<CollapsableText
maxLines={1}
moreLabel={<strong>SHOW IT</strong>}
lessLabel={<strong>HIDE IT</strong>}
>
Hello World <br /> Wow
</CollapsableText>
);
const button = component.find(Button);
button.simulate('click');
assert.equal(component.text(), 'Hello World Hide it from me');
await userEvent.click(screen.getByText('SHOW IT'));
expect(await screen.findByText('HIDE IT')).toBeInTheDocument();
});
});
9 changes: 6 additions & 3 deletions src/components/CollapsableText/CollapsableText.stories.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
import { boolean, number, text } from '@storybook/addon-knobs';
import React from 'react';
import Icon from '../Icon/Icon';
Expand All @@ -18,17 +19,19 @@ export default {
export const LiveExample = () => (
<CollapsableText
collapsed={boolean('collapsed', CollapsableText.defaultProps.collapsed)}
maxLength={number('maxLength', CollapsableText.defaultProps.maxLength)}
maxLines={number('maxLines', CollapsableText.defaultProps.maxLines)}
moreLabel={text('showMore', CollapsableText.defaultProps.moreLabel)}
lessLabel={text('lessLabel', CollapsableText.defaultProps.lessLabel)}
>
Some text <strong>with bold</strong> and <a href="#">links and other things</a>
<br />
{loremIpsum}
</CollapsableText>
);

export const ShorterThanMaxLength = () => (
export const ShorterThanMaxLines = () => (
<div>
<CollapsableText maxLength={number('maxLength', 2048)}>{loremIpsum}</CollapsableText>
<CollapsableText maxLines={number('maxLines', 2)}>Short text</CollapsableText>
</div>
);

Expand Down
94 changes: 55 additions & 39 deletions src/components/CollapsableText/CollapsableText.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,75 @@
import React, { useEffect, useState } from 'react';
import Button, { ButtonProps } from '../Button/Button';
/* eslint-disable react/no-unused-prop-types */
import React, { useState, useRef } from 'react';
import { useIntervalRef } from '../../hooks/useIntervalRef';
import Button from '../Button/Button';

const Toggle = ({ children, ...props }: ButtonProps) => (
<Button color="link" size="sm" className="p-0 m-0 ms-2" {...props}>
{children}
</Button>
);
function getEllipsisStyle(maxLines: number) {
maxLines = Math.max(maxLines, 1);
return {
display: '-webkit-box',
// max-height for browsers that don't support -webkit-line-clamp.
maxHeight: `calc(1.5em * ${maxLines})`,
overflow: 'hidden',
WebkitLineClamp: maxLines,
WebkitBoxOrient: 'vertical',
} as const;
}

export interface CollapsableTextProps {
children?: string;
children?: React.ReactNode;
collapsed?: boolean;
lessLabel?: React.ReactNode;
maxLength?: number;
moreLabel?: React.ReactNode;
/** @deprecated maxLength has no effect. Use maxLines instead */
maxLength?: number;
maxLines?: number;
}

const CollapsableText = ({
children = '',
collapsed: defaultCollapsed = true,
lessLabel = 'Show Less',
maxLength = 256,
moreLabel = 'Show More',
}: CollapsableTextProps) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const toggle = () => setCollapsed(!collapsed);
export default function CollapsableText({
children,
collapsed = CollapsableText.defaultProps.collapsed,
lessLabel = CollapsableText.defaultProps.lessLabel,
moreLabel = CollapsableText.defaultProps.moreLabel,
maxLines = CollapsableText.defaultProps.maxLines,
}: CollapsableTextProps) {
const [isCollapsed, setIsCollapsed] = useState(collapsed);
const [hideToggle, setHideToggle] = useState(collapsed);

useEffect(() => setCollapsed(defaultCollapsed), [defaultCollapsed]);
// Can't easily use a resize utility here because nothing can detect when only scrollHeight changes.
// Using an interval is not ideal, but it works.
const checkSizeRef = useIntervalRef((el) => {
setHideToggle(isCollapsed && el.clientHeight >= el.scrollHeight);
}, 200);

if (children.length < maxLength) {
return children;
}
if (collapsed) {
return (
<span>
{children.substring(0, maxLength).trim()}&hellip;{' '}
<Toggle onClick={() => toggle()}>{moreLabel}</Toggle>
</span>
);
// If the collapsed prop changes, update the state.
// This is bad practice, we should instead have a defaultCollapsed prop and the collapsed prop
// should fully control the state if provided.
// TODO: Add defaultCollapsed and onToggle props. Always respect collapsed prop unless undefined.
const lastCollapsedProp = useRef(collapsed);
if (lastCollapsedProp.current !== collapsed) {
lastCollapsedProp.current = collapsed;
setIsCollapsed(collapsed);
}

const textContainerStyle = isCollapsed ? getEllipsisStyle(maxLines) : undefined;

return (
<span>
{children} <Toggle onClick={() => toggle()}>{lessLabel}</Toggle>
</span>
<div className="d-inline-flex flex-column align-items-start mw-100">
<div ref={checkSizeRef} style={textContainerStyle}>
{children}
</div>
{!hideToggle && (
<Button color="link" onClick={() => setIsCollapsed((c) => !c)}>
{isCollapsed ? moreLabel : lessLabel}
</Button>
)}
</div>
);
};
}

CollapsableText.defaultProps = {
children: '',
collapsed: true,
lessLabel: 'Show Less',
maxLength: 256,
maxLines: 2,
moreLabel: 'Show More',
};

CollapsableText.displayName = 'CollapsableText';

export default CollapsableText;
27 changes: 27 additions & 0 deletions src/hooks/useIntervalRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react';
import { useLatestFn, useLatestRef } from './useLatest';

// Returns a callback ref to be used on an element.
// The callback will be called every `intervalMs` milliseconds with the element as an argument.
export function useIntervalRef<T extends Element>(callback: (el: T) => any, intervalMs = 200) {
const elRef = useRef<T>();
const timeoutRef = useRef<number>();
const intervalMsRef = useLatestRef(intervalMs);
const cb = useLatestFn(callback);

const cleanup = () => clearTimeout(timeoutRef.current);
const runInterval = () => {
cb(elRef.current!);
timeoutRef.current = window.setTimeout(runInterval, intervalMsRef.current);
};

useEffect(() => cleanup, []);

return (el: T | null) => {
if (el && elRef.current !== el) {
elRef.current = el;
cleanup();
runInterval();
}
};
}
19 changes: 19 additions & 0 deletions src/hooks/useLatest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useRef } from 'react';

// Returns a ref that always has the last provided value.
// This is useful for cases where you need to access the
// latest value of a prop inside a callback, e.g. a useEffect callback.
export function useLatestRef<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}

// Creates a stable function that always calls the last version of the provided function.
// It's mainly used for when useEffect subscribes to something and passes a callback.
// The changing of that callback would normally cause the effect to re-run, unsubscribing and resubscribing.
// This hook prevents that from happening while keeping the callback up to date.
export function useLatestFn<T extends (...args: any[]) => any>(fn: T) {
const ref = useLatestRef(fn);
return useRef((...args: any[]) => ref.current(...args)).current as T;
}

0 comments on commit 1820016

Please sign in to comment.