ember-truncate
Version:
A generic component used to truncate text to a specified number of lines.
276 lines (244 loc) • 6.57 kB
JavaScript
import { readOnly, not } from '@ember/object/computed';
import { set, action } from '@ember/object';
import Component from '@ember/component';
import { computed } from '@ember/object';
import ResizeHandlerMixin from 'ember-singularity-mixins/mixins/resize-handler';
import clamp from 'ember-truncate/utils/clamp';
import layout from 'ember-truncate/templates/components/truncate-multiline';
import diffAttrs from 'ember-diff-attrs';
import { inject as service } from '@ember/service';
import { scheduleOnce } from '@ember/runloop';
import { assert } from '@ember/debug';
const cssNamespace = 'truncate-multiline';
/**
* A generic component used to truncate text to a specified number of lines.
*
* It can be used in an inline form
*
* ```
* {{truncate-multiline text="foo bar"}}
* ```
*
* or a block form.
*
* ```
* {{#truncate-multiline}}
* foo bar
* {{/truncate-multiline}}
* ```
*
* @class SharedShowMoreTextMultilineComponent
*/
export default Component.extend(ResizeHandlerMixin, {
layout: layout,
/**
* Document service uses the browser document object or falls back to a simple-dom
* implementation.
*/
document: service('-document'),
/**
* The text to truncate. This is overridden if the block form is used.
* @type {string}
*/
text: '',
/* Default value to see Less */
seeLessText: 'see less',
/* Default value to see More */
seeMoreText: 'see more',
/**
* The number of lines at which to begin truncation.
* @type {number}
* @default 3
*/
lines: 3,
/**
* Whether or not to truncate the text. Can be used to control truncation from outside of the
* component.
* @type {boolean}
*/
truncate: true,
/**
* Getter/setter for internal truncate state. Handles resetting the button destination.
* @type {boolean}
* @private
*/
_truncate: computed('__truncate', {
get() {
return this.__truncate;
},
set(key, value) {
if (!value) {
this.set('_buttonDestination', null);
}
return set(this, '__truncate', value);
},
}),
/**
* Internal state of whether or not to truncate the text.
* @type {boolean}
* @private
*/
__truncate: true,
/**
* Whether the text is being truncated or not. Passed to the block context as `isTruncated`.
* @property isTruncated
* @type {boolean}
* @readonly
*/
isTruncated: readOnly('_truncate'),
/**
* Whether the text needed truncating or was short enough already.
* @property neededTruncating
* @type {boolean}
* @readonly
*/
neededTruncating: readOnly('_neededTruncating'),
/**
* Internal state of whether or not the text needed truncating.
* @type {boolean}
* @private
*/
_neededTruncating: false,
/**
* Keeps track of whether or not _doTruncate has been run.
* @type {boolean}
* @private
*/
_didTruncate: false,
/**
* The element into which the wormhole will render the truncation toggle button.
* @type {HTMLElement}
* @private
*/
_buttonDestination: null,
/**
* Whether the wormhole should render the button in place instead of moving it into the
* destination.
* @type {boolean}
* @private
*/
_buttonInPlace: not('_buttonDestination'),
/**
* Resets the component when the `text` attribute of the component has changed
* @return {Void}
*/
didReceiveAttrs: diffAttrs(
'lines',
'text',
'truncate',
function (changedAttrs) {
// `changedAttrs` will be null for the first invocation
// short circuiting for this case makes `didReceiveAttrs` act like `didUpdateAttrs`
if (changedAttrs == null) {
return;
}
if ('truncate' in changedAttrs) {
this.set('_truncate', this.truncate);
}
if (
'text' in changedAttrs ||
'truncate' in changedAttrs ||
'lines' in changedAttrs
) {
this._resetState();
}
}
),
/**
* Kicks off the truncation after render.
* @return {Void}
*/
didRender() {
this._super(...arguments);
if (!this._didTruncate && this._truncate) {
scheduleOnce('afterRender', this, this._doTruncation);
}
},
/**
* Resets the state of the component.
* @return {Void}
* @private
*/
_resetState() {
const truncate = this._truncate;
if (truncate) {
// trigger a rerender/retruncate
this.setProperties({
_didTruncate: false,
_truncate: false,
});
scheduleOnce('afterRender', this, () => {
this.set('_truncate', truncate);
});
}
},
/**
* Does the truncation by calling the clamp utility.
* @return {Void}
* @private
*/
_doTruncation() {
const doc = this.document;
const el = this.element.querySelector(
`.${cssNamespace}--truncation-target`
);
// TODO: make the assertion message more descriptive
assert(
'must use the `target` component from the yielded namespace',
el instanceof HTMLElement
);
clamp(
el,
this.lines,
(didTruncate) => this.set('_neededTruncating', didTruncate),
`${cssNamespace}--last-line`,
doc
);
const ellipsizedSpan = el.lastChild;
el.removeChild(ellipsizedSpan);
const wrappingSpan = doc.createElement('span');
wrappingSpan.classList.add(`${cssNamespace}--last-line-wrapper`);
wrappingSpan.appendChild(ellipsizedSpan);
this.set('_buttonDestination', wrappingSpan);
el.appendChild(wrappingSpan);
this.set('_didTruncate', true);
},
/**
* Kicks off truncation on resize by triggering a rerender.
* @return {Void}
*/
resize() {
this._resetState();
},
/**
* Returns false so truncation does not happen twice on insert.
* @property resizeOnInsert
* @type {boolean}
*/
resizeOnInsert: false,
/**
* Called by the "see more/see less" button. Toggles truncation.
* @return {Void}
*/
_toggleTruncate: action(function () {
let wasTruncated = this._truncate;
this.toggleProperty('_truncate');
if (wasTruncated) {
const onExpand = this.onExpand;
if (typeof onExpand === 'function') {
onExpand();
}
} else {
// Need to reset state when the text is retruncated via the 'See Less' button
this._resetState();
const onCollapse = this.onCollapse;
if (typeof onCollapse === 'function') {
onCollapse();
}
}
const onToggle = this.onToggle;
if (typeof onToggle === 'function') {
onToggle(!wasTruncated);
}
}),
});