Skip to content

Instantly share code, notes, and snippets.

@leMaur
Last active September 29, 2019 11:55
Show Gist options
  • Select an option

  • Save leMaur/b76539dc55718f36a3bb48afc1cbd852 to your computer and use it in GitHub Desktop.

Select an option

Save leMaur/b76539dc55718f36a3bb48afc1cbd852 to your computer and use it in GitHub Desktop.
Collapsible Section Render-less Component. [scroll down to see how to use it]. Inspired by https://inclusive-components.design/collapsible-sections
<script>
export default {
props: {
open: {
type: Boolean,
default: false,
},
},
data() {
return {
expanded: this.open,
identifier: null,
heading: null,
content: [],
}
},
watch: {
expanded: {
handler(value) {
// Update the hash if the collapsible section's
// heading has an `id` and we are opening, not closing.
if (this.identifier && value) {
history.pushState(null, null, '#' + this.identifier);
}
this.$emit('expanded', value)
},
deep: true,
},
},
created() {
let nodes = this.$slots.default
// The first element MUST be a heading with appropriate level, otherwise warn the user.
if (! this.checkHeading(nodes[0])) {
this.warn()
}
// Extract heading content.
this.heading = this.getHeadingContent(nodes[0])
// Extract the heading identifier.
this.identifier = this.getHeadingIdentifier(nodes[0])
// Get contents except heading.
for (let i = 1; i < nodes.length; i++) {
if (! this.checkHeading(nodes[i]) && undefined !== nodes[i].tag) {
this.content.push(nodes[i])
}
}
},
mounted() {
/**
* Defines if the section must be expanded and focused.
*/
this.mustLoadExpanded()
},
render: function(h) {
// Get existing button class.
let btnClass = (this.heading.button && this.heading.button.data && this.heading.button.data.staticClass)
? this.heading.button.data.staticClass : null
// Get icons and apply toggleClass
let btnContent = [this.heading.text || this.heading.button.children[0].text]
for (let i = 0; i < this.heading.content.length; i++) {
let _attrs = null, _class = null, _toggleClassOpen = null, _toggleClassClose = null
if (this.heading.content[i].data && this.heading.content[i].data.attrs) {
_attrs = this.heading.content[i].data.attrs
}
if (this.heading.content[i].data && this.heading.content[i].data.attrs) {
_toggleClassOpen = this.heading.content[i].data.attrs['data-class-opened']
_toggleClassClose = this.heading.content[i].data.attrs['data-class-closed']
}
if (this.heading.content[i].data && this.heading.content[i].data.staticClass) {
_class = this.heading.content[i].data.staticClass
}
btnContent.push(h(this.heading.content[i].tag, {attrs: _attrs, class: [_class, [this.expanded ? _toggleClassOpen : _toggleClassClose]]}, [this.heading.content[i].children]))
}
return h('div', {attrs: {role: 'region'}, class: 'collapsible-section'}, [
h(this.heading.tag, {attrs: {id: this.identifier}, class: `collapsible-heading ${this.heading.classes}`}, [
h('button', {ref: `${this.identifier}-button`, attrs: {'aria-expanded': this.expanded.toString()}, on: {click: this.toggle}, class: btnClass}, btnContent)
]),
h('div', {class: ['collapsible-content', {hidden: !this.expanded}]}, this.content)
])
},
methods: {
/**
* Defines if the section must be expanded and focused.
*/
mustLoadExpanded() {
if (window.location.hash.substr(1) === this.identifier) {
this.expanded = true
this.$refs[`${this.identifier}-button`].focus()
}
},
/**
* Expand or collapse the section.
*/
toggle() {
this.expanded = ! this.expanded
},
/**
* @param node Vnode
* @return String
*/
getHeadingIdentifier(node) {
let identifier = this.kebabCase(this.heading.text || this.heading.button.children[0].text)
if (node.data.attrs && node.data.attrs.id) {
identifier = node.data.attrs.id
}
return identifier
},
/**
* @param node Vnode
* @return Object
*/
getHeadingContent(node) {
let tag = node.tag, classes = null, text = null, button = null, content = []
// Get classes from heading element.
if (node.data && node.data.staticClass) {
classes = node.data.staticClass
}
// Get text or button element from heading element.
if (undefined !== node.children[0].text) {
text = node.children[0].text
} else {
if (node.children[0].tag === 'button') {
button = node.children[0]
}
}
// Looking for icons inside heading element.
for (let i = 1; i < node.children[0].children.length; i++) {
if (node.children[0].children[i].tag === 'svg') {
content.push(node.children[0].children[i])
}
}
return {tag, classes, text, button, content}
},
/**
* @param node Vnode
* @return Boolean
*/
checkHeading(node) {
return /h[1-6]/i.test(node.tag)
},
/**
* @throws Exception
*/
warn() {
console.warn('The first element inside each <collapsible-section> should be a heading of an appropriate level.')
},
/**
* @param string String
* @returns String
*/
kebabCase(string) {
return string.toLowerCase().replace(/\W+/g, '-').replace(/(^-|-$)/g, '')
},
}
}
</script>
@leMaur
Copy link
Copy Markdown
Author

leMaur commented Sep 29, 2019

How to use it

<!-- We can specify if must be expanded and focused by using "open" -->
<collapsible-section open>

  <!-- The first element MUST be a heading -->
  <h2>
    <button>

      Title of the section

      <!-- We can specify separate classes for opened and closed state -->
      <!-- Icon taken from https://feathericons.com -->
      <svg data-class-opened="" data-class-closed="" stroke="current" stroke-width="2" fill="none" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="9 18 15 12 9 6"></polyline></svg>
    </button>
  </h2>

  <p>content...</p>
  <p>something else...</p>

</collapsible-section>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment