Use more HTML Elements - Expand/Collapse with details
Tags: css, html, webdev, javascript
Published: 2020-06-04 16:12:33

Before you continue...

> This will use JavaScript. A little bit of it.

This time I explore the `details` and `summary` elements. 

- Using JavaScript/CSS to transition `max-height` we can achieve auto-dimension height effect, while doing collapse/expand of the details element.
- Even if `JavaScript` is disabled, the user will be able to see the hidden contents, without the auto-dimension effect.

More about these elements in the [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) page.

> Sadly, these two elements are not supported in IE

## Expectation 

![Auto-dimension height](https://dev-to-uploads.s3.amazonaws.com/i/3nojfxeyc4u3sk2cljbc.gif)

## Implementation

{% codepen https://codepen.io/icyJoseph/pen/MWKYKWO default-tab=js,result %}

First thing, the `HTML`. The contents of the `summary` tags are always shown. Upon user interaction, the other children of details are shown. 

For this demo I'll work with only two children, one of which is `summary`. However the implementation can be adapted to account for many children, or your `HTML` can be written so that you always have one child besides the `summary` tag.

```html
<details>
	<summary>Details</summary>
	<p>Some content that reveals more details</p>
</details>
```

Next the styling, this time the `CSS` will be very simple.

```css
details {
  height: auto;
  overflow: hidden;
  transition: max-height ease-in-out;
  transition-duration: var(--duration, 0.3s);
}

summary {
    cursor: pointer;
}
```

Notice I am using a `CSS` variable with a default value of `0.3s`. 

Finally the magic, JavaScript.

- Somehow gain access to the `details` element DOM node
- Attach a `click` event listener

When the click event happens

- Prevent the event default behavior
- Calculate the `initial` height of the `details` element
- Calculate the `next` value, flipping the current `details.open` value

**If** we are opening

- Immediately open it! The hidden overflow `CSS` property and the `max-height`, will prevent the content from leaking.
- Calculate the `height` of the hidden content, and add it to the `initial` height
- Set this as the `max-height` of the details element, this triggers the `transition`

**Else**, if we are closing

- set the max-height to the `initial` value
- create a timeout with duration equal to the duration of the transition
- when timeout runs its course, set the `next` value on the `details` element

```javascript
const details = document.querySelector('details')

const initial = details.offsetHeight
const duration = 600
let height = 0

details.setAttribute(
	'style',
	`--duration: ${duration / 1000}s; max-height: ${initial}px;`
)

details.addEventListener('click', e => {
	e.preventDefault()

	const next = !details.open

	if (next) {
		details.open = next

		if (document.createRange) {
			let range = document.createRange()
			range.selectNodeContents(details.lastElementChild)
			if (range.getBoundingClientRect) {
				let rect = range.getBoundingClientRect()
				if (rect) {
					height = rect.bottom - rect.top
				}
			}
		}
		
		details.setAttribute(
            'style',
            `--duration:${duration / 1000}s; max-height: ${initial + height}px;`
		)
	
	} else {
		details.setAttribute(
			'style',
			`--duration: ${duration / 1000}s; max-height: ${initial}px;`
		)
		setTimeout(() => {
			details.open = next
		}, duration)
	}
})
```

That's a lot of code 🤯. Let's refactor. I am not a fan of wrapping native stuff, but I'll be using this quite a bit.

```javascript
function setInlineAttribute({ element, duration, maxHeight }) {
	element.setAttribute(
		'style',
		`--duration: ${duration / 1000}s; max-height: ${maxHeight}px;`
	)
}
```

Isolate the range bounding client rectangle bit. This one is incredibly important, because it allows us to have a precise measure of what the maximum height should be, ensuring the **transitions last exactly the time we want**. More on the [`range` API](https://developer.mozilla.org/en-US/docs/Web/API/Range/Range).

> Sadly the range API is also not supported in IE

```javascript
function calculateContentHeight(element) {
	if (document.createRange) {
		let range = document.createRange()
		range.selectNodeContents(element.lastElementChild)
		if (range.getBoundingClientRect) {
			let rect = range.getBoundingClientRect()
			if (rect) {
				return rect.bottom - rect.top
			}
		}
	}
	return 0
}
```

A function to collect initial values, set styles and attach the click event listener.

```javascript
function animateDetailsElement(element, duration = 600) {
	const initial = element.offsetHeight
	let height = 0

	setInlineAttribute({ element, duration, maxHeight: initial })

	element.addEventListener('click', e => {
		e.preventDefault()

		const next = !element.open

		if (next) {
			element.open = next
			height = calculateContentHeight(element)
			setInlineAttribute({ element, duration, maxHeight: initial + height })
			
		} else {
			setInlineAttribute({ element, duration, maxHeight: initial })
			setTimeout(() => {
				element.open = next
			}, duration)
		}
	})
}

const details = document.querySelector('details')
animateDetailsElement(details)
```

Why do we calculate the content height and apply it as an in-line style, containing max-height and duration `CSS` variable?

One of the easiest techniques to create expand/collapse, is to transition the `max-height`, but in this article on [auto-dimensions](https://css-tricks.com/using-css-transitions-auto-dimensions/) author Brandon Smith, points out two disadvantages of it.

> The obvious disadvantage is that we still have to hard-code a maximum height for the element, even if we don’t have to hard-code the height itself.  The second, less obvious downside, is that the transition length will not actually be what you specify unless the content height works out to be exactly the same as `max-height`. - [Brandon Smith](https://css-tricks.com/using-css-transitions-auto-dimensions/)

The approach taken here, has a few advantages.

- Manages open/close state, through the details element
- Helps you to calculate the maximum height needed for your content
- Because you calculate the exact maximum height, the duration of the transition will be what you specify

And the downside that it requires `JavaScript`.

In this implementation I've also put effort into having the duration be declared in the JavaScript side, and then transmitted to the `CSS` using an in-line `CSS` variable. That's ugly, but it works.

Refactoring further to reduce the scope of the `height` variable, and have a means to remove the event listener.

```javascript
function animateDetailsElement(element, duration = 600) {
    let initial = element.offsetHeight

	setInlineAttribute({ element, duration, maxHeight: initial })
    
    function handler(e) {
        e.preventDefault()

		const next = !element.open

		if (next) {
			element.open = next
			let height = initial + calculateContentHeight(element)
			setInlineAttribute({ element, duration, maxHeight: height })
			
		} else {
			setInlineAttribute({ element, duration, maxHeight: initial })
			setTimeout(() => {
				element.open = next
			}, duration)
		}
    }

	element.addEventListener('click', handler);
    
    return () => element.removeEventListener('click', handler);
}

const details = document.querySelectorAll("details");
details.forEach(el => animateDetailsElement(el)) 
// .forEach(animateDetailsElement) would cause the duration to equal the index of el
```

We've accomplished a re-usable expand/collapse effect.

> The `details` element fires a `toggle` event once the its value changes. Since this will fire when the `element.open = next` assignment happens, you could do further things, knowing the animation has finished.

Perhaps you don't like the triangle shown, the `summary` element can further be styled, though support is a little patchy.

```css
details > summary {
    list-style: none;
}
/* Chrome fix */
details > summary::-webkit-details-marker {
  display: none;
}
```

**What do you think?**

Sometimes JavaScript is necessary to create a smoother experience, **but it shouldn't prevent the experience from happening if JavaScript is blocked by the user**.

> In this case, if the user deactivated JavaScript, we'd still have the expand/collapse functionality, though content would just pop into existence.

Happy Hacking!