So… let’s talk about building an Accordion in Salesforce Lighting/Aura. Before you close your tab and after you think, “WHY? Salesforce has a set of accordion components already! Tell me something I don’t know!” Let me answer your first question: “Why.”
So, normally i would would agree, its better to use an OOTB component, but the Lightning Accordion is a particular pain in the butt. First-off, you always need to use <lightning:accordion />
together with <lightning:accordionSection />
. This is how you control open states of accordions. You need to use the activeSectionName
attribute on <lightning:accordion />
where you’ve passed a single value (or array of values!) which has to match up with the name
attribute on the <lightning:accordionSection />
, which will then open the accordions you need. This gets to be particularly difficult when using a list of data (like a list of Salesforce records) to display a dynamic set of accordions. You’re also limited to the default attributes, so you can’t integrate a sub-title or a tooltip, both of which are common.
So, occasionally for our clients at 7Summits, we end up writing our own <c:Accordion />
as a Lightning component. In addition to solving the implementation difficulty issues, it also gives us the chance to customize and enhance the design, animation, and overall UI of the interface. I’d like to show you a really simple version of an Accordion
in order to get you started creating your own.
A Basic Accordions, With Just Show/Hide
Similarly to my previous Modal component, we can start with a basic “show/hide” layout. This uses a single isOpen
attribute, and shows/hides the content of the Accordion (by adding the .slds-is-open
class based onthat attribute). Simple and clean, but leaves something to be desired from a UI perspective. It provides us with a great start, however. So let’s put that into motion. We’ll use basic SLDS styling to get started for this one.
<!-- Accordion.cmp -->
<aura:component description="Accordion">
<aura:attribute name="title" type="String" default="Title" />
<aura:attribute name="isOpen" type="Boolean" default="false" /> <div class="slds-accordion__list-item">
<section class="{!'slds-accordion__section '+ if(v.isOpen, 'slds-is-open', '')}">
<div class="slds-accordion__summary">
<h2 class="slds-accordion__summary-heading slds-text-heading_small">
<button onclick="{!c.handleClick}" class="slds-button_reset">
{!v.title}
</button>
</h2>
</div>
<div class="slds-accordion__content">
{!v.body}
</div>
</section>
</div>
</aura:component>
Now that’s about as basic an accordion as you can get. There’s a lot that would needed to be added, even from an HTML perspective. Please don’t forget about accessibility (aria- attributes, for instance), icons for usability, and any custom styling.
With this type of setup, the controller method is quite simple:
/* AccordionController.js */
({
handleClick: function(component, event, helper) {
var isOpen = component.get("v.isOpen");
component.set("v.isOpen", !isOpen);
}
})
Here, we’re just taking the onclick
handler of the button
, and toggling the isOpen
attribute of the accordion, which shows or hides its body. At this point, you can add the correct implements
attribute to <aura:component>
and you have a usable component.
Animating the Height of the Accordion
OK, before we get to the more complex work in the Accordion
component, let’s talk animation. When creating an animation in a browser, it’s best to try and use CSS to handle it. Usually, this consists of a transition statement, in an example such as this:
/* Accordion.css */
.THIS .slds-accordion__content {
/* Some visibility/opacity styling to override the default SLDS styling */
visibility: visible;
opacity: 1;
height: 0;
transition: height .5s ease-in-out;
}
.THIS .slds-is-open > .slds-accordion__summary {
margin-bottom: 0;
}
.THIS .slds-is-open > .slds-accordion__content {
overflow: hidden;
height: 200px;
}
That is simple, and works great, however, we have an issue — we don’t know the height of the .slds-accordion__content
element when it’s open!
A generic accordion (or similar component) is going to need to be used in a wide variety of use-cases, and may have a varying amount of content in the body. It may be a single sentence, or an expanded image, or a full few paragraphs. Even in what may be its most common application, a FAQ list, it’s going to have a lot of different heights to deal with.
There are a TON of CSS-only techniques that try and solve this with a variety of different tricks, from animating the max-height
, to just leaving any gaps create, or more. They all have downsides, however – from unavoidable delays after closing the modal, to unrefined layouts. Really, it’s just best to use JS to animate the height. So we need to set the height of our accordion content, based on the DOM element.
So, here’s where we’re at:
- We want to animate height of the
.slds-accordion__content
element with CSS. - We won’t know the height of that element, so we can’t set it in our CSS file, and it needs to be done with JS.
- We’ll need to get the height of the element within Salesforce Lightning’s framework.
Render
events in Salesforce Lightning
So the Salesforce documentation talks a little bit about modifying DOM elements within the Aura/Lightning framework. It’s quite helpful, and the technique we’re going to need to use. We need to look at using the render
event on the component. Here’s the problem we’re avoiding with this issue:
If we try and write a onInit
handler, like this:
<!-- Accordion.cmp -->
...
<aura:handler name="init" value="{!this}" action="{!c.onInit}" />
...
<div class="slds-accordion__content" aura:id="accordionContent">
...
</div>
...
… and the method that handles it like this:
/* AccordionController.cmp */
onInit: function(component, event, helper) {
var contentHeight = component.find("accordionContent").getElement().offsetHeight;
console.log(contentHeight);
}
You’re going to run into a problem. Chances are, your component won’t show on the page, and it’s because the init
event doesn’t guarantee that the DOM elements are going to be in place yet. Instead, you need to use that render
event handler to get the height of an element reliably, as follows:
<!-- Accordion.cmp -->
...
<aura:handler name="render" value="{!this}" action="{!c.onRender}" />
...
<div class="slds-accordion__content" aura:id="accordionContent">
...
</div>
...
Controller:
/* AccordionController.js */
onRender: function(component, event, helper) {
var contentHeight = component.find("accordionContent").getElement().offsetHeight;
console.log(contentHeight);
}
Now, your console.log()
statement won’t error out, and you can use this information along with a bit of Lightning/CSS trickery to get your animation working properly. The render
event will render any time the DOM changes in your component or your Lightning Component needs to re-render. This means that we can keep this onRender
handler, and use it in tandem with the handleClick
method we created before, so that every time the isOpen
state is toggled (or another change is made), the onRender
method will re-run. That’s really useful, and exactly what we want moving forward.
Now we know how to get the height of the element, when it is open. The next issue we need to solve is getting the height in its closed state. To do that, we can write a helper method. We’ll call it getHeight
, of course, and we’ll want to pass it the DOM element that we need to find the height of. (Basically, the component.find("accordionContent").getElement()
statement from above.)
/* AccordionHelper.js */
({
getHeight: function (element) {
element.style.display = 'block'; // Make it visible
var height = element.scrollHeight + 'px'; // Get it’s height
element.style.display = ''; // Set it back!
return height;
}
})
Essentially, what’s happening here is that we need to force the element to be shown so we can properly get the element
’s height, then quickly hide it again before it’s shown to the user. Then, we can return the height
that we just calculated.
Putting it all together
OK, now we just need to assemble this all a bit.
First, let’s update our controller onRender
method to use slideDown
and slideUp
methods that we’ll create in our helper:
/* AccordionController.cmp */
onRender: function(component, event, helper) {
var isOpen = component.get("v.isOpen");
if (isOpen) {
helper.slideDown(component, event, helper);
} else {
helper.slideUp(component, event, helper);
}
}
Most of the following technique is based on some of the logic in my previous Animating Modals in Salesforce Lightning blog post, specifically where we use a closed > opening > open > closing > closed
cycle to control the different states. Let’s adjust our CSS to account for those states, and add a basic half-second CSS animation:
/* Accordion.css */
.THIS .slds-accordion__content {
visibility: visible;
opacity: 1;
display: block;
height: 0;
transition: height .5s ease-in-out;
}
.THIS .slds-accordion__summary {
margin-bottom: 0;
}
.THIS .accordion-state_opening > .slds-accordion__content,
.THIS .accordion-state_closing > .slds-accordion__content {
overflow: hidden;
}
.THIS .accordion-state_open > .slds-accordion__content {
/* Note we don’t add in the "open" state height! */
height: auto;
}
.THIS .accordion-state_closed > .slds-accordion__content {
display: none;
}
Now, let’s walk through the slideDown
helper method:
/* AccordionHelper.js */
({
...
// Shows an element
slideDown: function (component, event, helper) {
var accordionElement = component.find("accordionContent").getElement();
var isOpenState = component.get("v.isOpenState"); // Get the natural height of the element
if(accordionElement && isOpenState === 'closed') {
var height = helper.getHeight(accordionElement); // Get the natural height
accordionElement.style.height = height; // Update the height
component.set("v.isOpenState", 'opening'); // Once the transition is complete, remove the inline height so the content can scale responsively
setTimeout($A.getCallback(function () {
accordionElement.style.height = '';
component.set("v.isOpenState", 'open);
}), 500);
}
},
...
The slideDown
method first finds a reference to the aura:id="accordionContent"
element in the component, then gets the actual DOM element using .getElement()
. We need the actual DOM element to both find the height of the element, and add that directly to an inline style.
(Occasionally, you may need to adjust the var accordionElement
line if you’re getting errors that looks something like “Cannot getElement() of undefined.
” This adjustment could look something like the following:)
var accordion = component.find(“accordionContent”);
if(accordion) {
var accordionElement = accordion.getElement();
}
Also, we’re going to be using slideDown
in the render
event handler, which will fire every time the component changes, which means we need to add a bit of logic to only run the code when we need it to, in order to avoid any looping or recursion. That’s where the if(isOpenState === 'closed')
comes in. The if(accordionElement)
is there just in case the render
event can’t find that element for some reason.
We use our getHeight
helper class to get the height of the element, and then set an inline style of that retrieved height. Then, we can set the isOpenState
to opening
, and wait half a second to set the isOpenState
to open
, while the CSS animation does it’s work.
Note also, that the setTimeout
also uses $A.getCallback()
, which is important so that Lightning doesn’t lose track of any changes we’re making to the component in the setTimeout
.
The slideUp
(or hide) method works very similarly, it’s as below:
/* AccordionHelper.js */
({
...
// Hide an element
slideUp: function(component, event, helper) {
var accordionElement = component.find(“accordionContent”).getElement();
var isOpenState = component.get(“v.isOpenState”); if(accordionElement && isOpenState === ‘open’) {
// Give the element a height to change from (we could also use getHeight() here)
accordionElement.style.height = accordionElement.scrollHeight + ‘px’;
// Set the height back to 0
setTimeout($A.getCallback(function () {
component.set(“v.isOpenState”, ‘closing’);
accordionElement.style.height = ‘0’;
}), 10); // When the transition is complete, hide it
setTimeout($A.getCallback(function () {
component.set(“v.isOpenState”, ‘closed’);
accordionElement.style.height = ‘’;
}), 500);
}
}
})
```
I’ve used a bit of setTimeout
silliness to properly get the animation to work, I’m sure it can use a bit of optimization, but for now this triggers the correct CSS animation states.
You can see the complete example on my Github:
https://github.com/jonbalza/lightning-accordion-example
Thanks for reading, I hope you find this useful. Please write a response with any suggestions or comments you may have.