-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1158 from appfolio/collapsable-any-children
CollapsableText: Allow for any children, move show more button to bottom
- Loading branch information
Showing
6 changed files
with
174 additions
and
92 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()}…{' '} | ||
<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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |