Responsively crop content copy down to a user-defined number of lines. Click to restore content via a drop-down sliding animation.
A Pen by mike foskett on CodePen.
<h1>Responsively crop copy, restore onclick via sliding drop-down animation</h1> | |
<p> | |
Responsively crop content copy down to a user-defined number of lines.<br> | |
Click to fully restore the content via a sliding drop-down animation.<br> | |
All delivered in an accessible manner.<br> | |
</p> | |
<p>As used on Tesco's <a href="http://www.tesco.com/food-love-stories/">Food Love Stories</a></p> | |
<p>GitHub repo available: <a href="https://github.com/2kool2/crop-copy-slide-restore" target=_blank title="[new window]">crop-copy-slide-restore</a></p> | |
<section> | |
<h2>Single line crop example</h2> | |
<div class=CCR> | |
<div class=CCR_txt | |
data-cropCopyRestore> | |
If you’ve seen our recent TV ad, you’ll be in on our Food Love Story about ‘David’s’ shameful secret: when he met his wife, he fibbed about sharing her love of spicy food. 15 years later, he hasn’t come clean, but he’s still making his wife her favourite curry – with a sneaky dollop of cooling yogurt for him. | |
</div> | |
</div> | |
<h2>Two line crop example</h2> | |
<div class=CCR> | |
<div class=CCR_txt | |
data-cropCopyRestore=2> | |
Sometimes it’s the undemandingly easy, everyday recipes that deliver the most joy. For ‘Iain’ and his dad, from our Food Love Story, it’s croque monsieur. They first had it on a joint trip to France and, since then, it’s become their favourite weekend lunch. ‘Iain’s’ made a few changes to it along the way (bonjour, wafer-thin roast turkey) – but for him and his dad, it’s most definitely ‘proper’. | |
</div> | |
</div> | |
</section> | |
<svg style="display:none"> | |
<defs> | |
<symbol viewBox="0 0 38 38" id="icon-vert"> | |
<path d="M19 10.5l0 17"></path> | |
</symbol> | |
<symbol viewBox="0 0 38 38" id="icon-hori"> | |
<path d="M10.5 19l17 0"></path> | |
</symbol> | |
</defs> | |
</svg> | |
<h2>Features</h2> | |
<ul> | |
<li>User-defined number of lines initially displayed, defined in the HTML.</li> | |
<li>JavaScript automagically writes an accurate inline max-height property which is animated via CSS.</li> | |
<li>Resizing the viewport recalculates the length of text displayed and adjusts the max-height values.</li> | |
<li>Utilises <abbr title="Accessible Rich Internet Applications">ARIA</abbr> roles and live region to help meet <abbr title="Web Content Accessibility Guidelines">WCAG</abbr> 2 (accessibility).</li> | |
<li>Vanilla JavaScript and less than 2kB minified & gzipped.</li> | |
<li>Support down to IE9.</li> | |
</ul> | |
<h2>Status</h2> | |
<p> | |
Cross-browser tested:<br> | |
Mac: Firefox Dev, Chrome, Safari, Opera Dev.<br> | |
PC: Firefox Dev, Chrome, IE9 - Edge. | |
</p> | |
<p> | |
In Live testing.<br> | |
To be followed by full accessibility testing. | |
</p> | |
<p class=smaller><a target=_blank title="[new window]" href="https://codepen.io/2kool2/pens/public/?grid_type=list#">Pens by Mike Foskett</a> — <a target=_blank title="[new window]" href="https://websemantics.uk/">webSemantics</a></p> |
Responsively crop content copy down to a user-defined number of lines. Click to restore content via a drop-down sliding animation.
A Pen by mike foskett on CodePen.
// Crop copy responsively, to user-defined number of lines, then restore onclick - v2.0 (IE9+) - 22/01/2017 - M.J.Foskett - https://websemantics.uk/ | |
var cropCopyRestore = (function (window, document) { | |
"use strict"; | |
var dataAttr = "data-cropCopyRestore"; | |
var buttonId = "CCR_btn-"; | |
var ellipsis = "…"; // "\u2026" | |
var clonedClass = "CCR-clone"; | |
var expandedClass = "CCR-expanded"; | |
var textClass = "CCR_txt"; // html text content class | |
var copyClass = "CCR_copy"; // span added inside textClass to contain just the copy | |
var iconSVG = "<span class=CCR_icon><svg class=CCR_svg focussable=false><use class=CCR_use-plus xlink:href=#icon-vert></use><use xlink:href=#icon-hori></use></svg></span>"; | |
var readMoreSpan = "<span class=visually-hidden> [Read more]</span>"; | |
// Debounce window resize - https://john-dugan.com/javascript-debounce/ | |
var debounce=function(e,t,n){var a;return function(){var r=this,i=arguments,o=function(){a=null,n||e.apply(r,i)},s=n&&!a;clearTimeout(a),a=setTimeout(o,t||200),s&&e.apply(r,i)}}; | |
// transitionend event test and prefix - https://gist.github.com/O-Zone/7230245 | |
!function(n){var i={transition:"transitionend",WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"otransitionend"},t=document.createElement("div");for(var o in i)if("undefined"!=typeof t.style[o]){n.transitionEnd=i[o];break}}(window); | |
// Minimal classList polyfill (for IE9) - Devon Govett - https://gist.github.com/devongovett/1381839 | |
"classList"in document.documentElement||!Object.defineProperty||"undefined"==typeof HTMLElement||Object.defineProperty(HTMLElement.prototype,"classList",{get:function(){function e(e){return function(t){var s=n.className.split(/\s+/),i=s.indexOf(t);e(s,i,t),n.className=s.join(" ")}}var n=this,t={add:e(function(e,n,t){~n||e.push(t)}),remove:e(function(e,n){~n&&e.splice(n,1)}),toggle:e(function(e,n,t){~n?e.splice(n,1):e.push(t)}),contains:function(e){return!!~n.className.split(/\s+/).indexOf(e)},item:function(e){return n.className.split(/\s+/)[e]||null}};return Object.defineProperty(t,"length",{get:function(){return n.className.split(/\s+/).length}}),t}}); | |
// String cropping functions | |
function _removeLastOccur(str, removeStr) { | |
return str.substring(0, str.lastIndexOf(removeStr)); | |
} | |
function _removeTrailingPunct(str) { | |
return str.replace(/[ .,!?:;"“‘'\-]+$/, ""); | |
} | |
// Display and animation functions | |
function _display(obj, str) { | |
obj.querySelector("." + copyClass).textContent = str; | |
} | |
function _displayCroppedText(obj) { | |
_display(obj, obj.croppedText + ellipsis); | |
if (!obj.innerHTML.match("visually-hidden")) { | |
obj.innerHTML += readMoreSpan; | |
} | |
} | |
function _resetAttr(obj, bool) { | |
obj.setAttribute("aria-expanded", bool); | |
} | |
function _addRemainerText(obj) { | |
function __add(obj) { | |
_display(obj, obj.fullText); | |
obj.removeChild(obj.querySelector(".visually-hidden")); | |
_resetAttr(obj, true); | |
} | |
// maybe use a polyfill? | |
if (window.requestAnimationFrame) { | |
window.requestAnimationFrame(function() { | |
__add(obj); | |
}); | |
} else { | |
__add(obj); | |
} | |
} | |
function _removeRemainerText(obj) { | |
if (obj) { // Repairs an obscure "tap" condition in Chrome | |
_displayCroppedText(obj); | |
_resetAttr(obj, false); | |
} | |
} | |
// Set copy | |
function _createClone(obj, str) { | |
// create an invisible clone (used to get an objects height) | |
var clone = obj.cloneNode(true); | |
clone.classList.add(clonedClass); | |
if (clone.querySelector("." + copyClass)) { | |
clone.querySelector("." + copyClass).textContent = str; | |
obj.parentNode.insertBefore(clone, obj.nextSibling); | |
clone.initialHeight = clone.clientHeight; | |
} | |
return clone; | |
} | |
function _getCroppedHeight(obj) { | |
var clone =_createClone(obj, obj.croppedText); | |
obj.parentNode.removeChild(clone); | |
return clone.initialHeight; | |
} | |
function _getFullHeight(obj) { | |
var clone =_createClone(obj, obj.fullText); | |
obj.parentNode.removeChild(clone); | |
return clone.initialHeight; | |
} | |
function _getCroppedText(obj) { | |
var txtArr = obj.fullText.split(" "); | |
var i = 0; | |
var lines = 1; | |
var clone = _createClone(obj, txtArr[i] + " "); | |
var textObj = clone.querySelector("." + copyClass); | |
for (i = 1; i < txtArr.length; i++) { | |
textObj.textContent += txtArr[i] + ellipsis; | |
if (clone.clientHeight !== clone.initialHeight) { | |
if (lines + "" === obj.noOfLines) { | |
_display(clone, _removeLastOccur(textObj.textContent, txtArr[i] + ellipsis)); | |
obj.croppedMaxHeight = clone.clientHeight; | |
obj.parentNode.setAttribute("style", "max-height:" + obj.croppedMaxHeight + "px"); | |
break; | |
} | |
lines++; | |
clone.initialHeight = clone.clientHeight; | |
} | |
// Bit of an assumption | |
_display(clone, textObj.textContent.replace(txtArr[i] + ellipsis, txtArr[i] + " ")); | |
} | |
_display(clone, _removeTrailingPunct(textObj.textContent)); | |
obj.parentNode.removeChild(clone); | |
return textObj.textContent; | |
} | |
// Handle events | |
function _removeText(event) { | |
var obj = event.target.p; | |
delete event.target.p; | |
event.target.removeEventListener(window.transitionEnd, _removeText); | |
_removeRemainerText(obj); | |
} | |
function _getButtonObj(obj) { | |
if (obj && obj.classList.contains(textClass)) { | |
return obj; | |
} | |
if (obj.parentNode === null) { | |
return false; | |
} | |
return _getButtonObj(obj.parentNode); | |
} | |
function _clicked(event) { | |
var obj = _getButtonObj(event.target); | |
if (obj) { | |
if (obj.getAttribute("aria-expanded") === "true") { | |
obj.parentNode.style.maxHeight = _getCroppedHeight(obj) + "px"; | |
obj.parentNode.classList.remove(expandedClass); | |
obj.parentNode.p = obj; | |
if (window.transitionEnd) { | |
obj.parentNode.addEventListener(window.transitionEnd, _removeText, false); | |
} else { | |
_removeRemainerText(obj); | |
} | |
} else { | |
obj.parentNode.style.maxHeight = _getFullHeight(obj) + "px"; | |
obj.parentNode.classList.add(expandedClass); | |
_addRemainerText(obj); | |
} | |
} | |
event.preventDefault(); | |
} | |
function _keyPressed(event) { | |
// Enter or space key | |
if (event.which === 13 || event.which === 32) { | |
_clicked(event); | |
} | |
} | |
function _addEvents(obj) { | |
obj.addEventListener("click", _clicked, false); | |
obj.addEventListener("keydown", _keyPressed, false); | |
} | |
function _removeEvents(obj) { | |
obj.removeEventListener("click", _clicked); | |
obj.removeEventListener("keydown", _keyPressed); | |
} | |
// Initialisation | |
function _initialiseAttributes(obj, i) { | |
var str = obj.getAttribute(dataAttr); | |
obj.noOfLines = (/^([1-9]\d*)$/.test(str)) ? str : "1"; // Returns 1 - 9 only | |
obj.fullText = obj.textContent.trim(); | |
obj.setAttribute("id", obj.id || buttonId + i); | |
obj.setAttribute("role", "button"); | |
obj.setAttribute("tabindex", "0"); | |
obj.setAttribute("aria-controls", obj.id); | |
} | |
function _prepareContent(obj) { | |
// Quick and dirty - replace if you wish | |
obj.innerHTML = iconSVG + "<span role=alert aria-live=assertive class=" + copyClass + ">" +obj.innerHTML + "</span>"; | |
} | |
function start() { | |
var objs = document.querySelectorAll("[" + dataAttr + "]"); | |
var i = objs.length; | |
var obj; | |
while (i--) { | |
obj = objs[i]; | |
// In case it's a resize call rather than initialisation | |
if (obj.fullText) { | |
obj.parentNode.classList.remove(expandedClass); | |
_removeEvents(obj); | |
} else { | |
_prepareContent(obj); | |
_initialiseAttributes(obj, i); | |
} | |
// Reset, or initialise, common attributes | |
_resetAttr(obj, false); | |
obj.croppedText = _getCroppedText(obj); | |
_displayCroppedText(obj); | |
_addEvents(obj); | |
} | |
} | |
start(); | |
window.addEventListener("resize", debounce(start, 100, false), false); | |
}(window, document)); | |
window.addEventListener("load", cropCopyRestore, false); |
/* Generics */ | |
code {display:block;} | |
pre {margin: 0; overflow-x: scroll;} | |
/* Helper classes */ | |
* {box-sizing: border-box;} | |
.visually-hidden { | |
position: absolute !important; | |
height: 1px; | |
width: 1px; | |
overflow: hidden; | |
clip: rect(1px, 1px, 1px, 1px); | |
} | |
/* Main styles */ | |
.CCR { | |
/* Animated via JS embedding inline max-height values */ | |
/* Note: 1ms shorter than SVG rotation duration */ | |
overflow: hidden; | |
transition: max-height .6s ease-out; | |
} | |
.CCR_txt { | |
/* Optional, adjust to meet individual project */ | |
color: #fff; | |
background-color: #000; | |
padding: .75rem 1rem; | |
} | |
.CCR_txt[role="button"] { | |
cursor: pointer; | |
} | |
/* Icon styles */ | |
.CCR_icon { | |
/* SVG container (required) */ | |
/* Fixes Safari's focus/hover state box-shadow */ | |
/* Override colours here if required: */ | |
/* color: #fff; */ | |
background-color: #3a3a3a; | |
float: right; | |
margin: 0 0 .75rem .75rem; | |
/* Today, we look through the round window */ | |
border-radius: 100%; | |
overflow: hidden; | |
display: block; | |
width: 1.5em; | |
height: 1.5em; | |
-webkit-transition: box-shadow .3s ease-out; | |
transition: box-shadow .3s ease-out; | |
} | |
.CCR_svg { | |
background-color: transparent; | |
color: currentColor; | |
border: .125em solid currentColor; | |
border: .125em solid #3a3a3a; | |
border-radius: 100%; | |
display: block; | |
width: 100%; | |
height: 100%; | |
stroke-width: 4; | |
stroke-linecap: square; | |
stroke: currentColor; | |
/* Note: 1ms longer than SVG rotation duration */ | |
-webkit-transition: transform .7s ease-out; | |
transition: transform .7s ease-out; | |
} | |
/* Icon animation */ | |
.CCR-expanded .CCR_svg { | |
/* 360deg provides a slower rotation */ | |
transform: rotateZ(180deg); | |
} | |
.CCR_use-plus { | |
/* Note: same as SVG rotation duration */ | |
-webkit-transition: opacity .7s ease-out; | |
transition: opacity .7s ease-out; | |
} | |
.CCR-expanded .CCR_use-plus { | |
opacity: 0; | |
} | |
/* Acts as focus state indicator for the control */ | |
/* A requirement to meet WCAG 2 */ | |
.CCR_txt:hover > .CCR_icon, | |
.CCR_txt:focus > .CCR_icon { | |
box-shadow: 0 0 0 4px #99BAD9; | |
} |
<link href="https://codepen.io/2kool2/pen/kXQoAA" rel="stylesheet" /> |