src/components/Form/Accordion/Accordion.jsx
import React from 'react'
import PropTypes from 'prop-types'
import { i18n } from 'config'
import ValidationElement from 'components/Form/ValidationElement'
import Branch from 'components/Form/Branch'
import Show from 'components/Form/Show'
import Svg from 'components/Form/Svg'
import StickyAccordionSummary from 'components/Sticky/StickyAccordionSummary'
import { findPosition } from 'components/Navigation/navigation-helpers'
/**
* This file has too many violations and is in fact relying on them in some cases to work as it
* does currently. Recommend rebuilding in a new component instead of trying to refactor or fix
* linting issues here.
*/
/* eslint-disable */
export const openState = (item = {}, initial = false) => {
return `${item.open ? 'open' : 'close'} ${
initial ? 'static' : 'animate'
}`.trim()
}
export const chevron = (item = {}) => {
return `toggle fa fa-chevron-${item.open ? 'up' : 'down'}`
}
export const doScroll = (first, item, scrollTo) => {
if (!first || !item || !scrollTo) {
return
}
// Get the position of the element we want to be visible
const pos = findPosition(document.getElementById(item.uuid))[0]
// Get the top most point we want to display at least on the first addition
const top = findPosition(scrollTo)[0]
// Find the offset from the top most element to the first item in the accordion for
// a fixed offset to constantly be applied
const offset = findPosition(document.getElementById(first))[0] - top
// This is the additional offset for bike shedding
const offsetDeux = 130
// Scroll to that position
window.scroll({ top: pos - offset - offsetDeux, left: 0, behavior: 'smooth' })
}
export const scrollToBottom = selector => {
const el = document.querySelector(selector)
if (!el) {
return
}
window.scroll({ top: el.offsetTop, left: 0, behavior: 'smooth' })
}
export default class Accordion extends ValidationElement {
constructor(props) {
super(props)
this.state = {
initial: props.initial,
scrollToId: ''
}
this.getItems = this.getItems.bind(this)
this.update = this.update.bind(this)
this.add = this.add.bind(this)
this.updateChild = this.updateChild.bind(this)
this.updateAddendum = this.updateAddendum.bind(this)
this.summary = this.summary.bind(this)
this.details = this.details.bind(this)
this.content = this.content.bind(this)
// Instance variable. Not stored in state to prevent re-renders and it's not going to
// be used in the UI.
this.stickyStatus = {}
}
/**
* On the initial mount we need to make sure there are key pieces of information
* present. If they are not (this may be due to coming from persisted data) assign
* them appropriately.
*/
componentWillMount() {
let dirty = false
let items = this.getItems()
if (items.length !== this.props.items.length) {
dirty = true
}
if (items.length < this.props.minimum) {
for (let i = 0; this.props.minimum - items.length > 0; i++) {
dirty = true
items.push({
uuid: super.guid(),
open: true
})
}
}
items = items.map(item => {
if (!item.uuid) {
item.uuid = super.guid()
dirty = true
}
if (item.open !== true && item.open !== false) {
item.open = this.props.defaultState
dirty = true
} else {
item.open = this.props.items.length > 1 ? false : this.props.defaultState
}
return item
})
if (dirty) {
this.update(items, this.props.branch)
}
}
/**
* When the component recieves an update we need to check if it is necessary to scroll an
* item in to view.
*/
componentDidUpdate() {
if (!this.state.scrollToId || this.state.initial) {
return
}
// Capture the UUID in a constant variable to ensure we don't lose scope
const id = this.state.scrollToId
// Reset the values to prohibit multiple calls due to various
// asynchronous behaviours potentially coming from outside this
// component
this.setState({ initial: false, scrollToId: '' }, () => {
// Find the item by UUID instead of index because we can't true the index
// will always be the same
const item = this.props.items.filter(x => x.uuid === id)[0] || {
uuid: id
}
// Calculate a magic number to phase the timeout value. This always
// for any CSS keyframe animations or transitions to take place prior
// to finding coordinates.
const index = this.props.items.findIndex(x => x.uuid === id)
const sindex = index < 0 ? 0 : index
const shift = (sindex / 10) * 0.3142
const timeout = this.props.timeout + this.props.timeout * shift
// Get the element to which we should scroll to
const scrollTo = this.props.scrollToTop
? document.getElementById(this.props.scrollToTop)
: this.refs.accordion
// Get the identifier to the first item
const first = this.props.items[0].uuid
if (timeout === 0) {
doScroll(first, item, scrollTo)
} else {
window.setTimeout(() => {
doScroll(first, item, scrollTo)
}, timeout)
}
})
}
/**
* Create a new item with required properties.
*/
newItem() {
return { uuid: super.guid(), open: true }
}
/**
* Perform any sorting to the list as deemed necessary.
*/
getItems() {
const { items, realtime, sort } = this.props
const { initial } = this.state
// If this has realtime enabled then we always perform sorting
//
// If it is not realtime but still the first entry in to the accordion
// then we do the same.
//
// If we have been previously infected then assume we still are.
const infected = realtime || initial
const innoculated = [...items]
// If we are not in a dirty environment and have a sorting function then
// apply order.
return sort && infected
? innoculated.sort(sort)
: innoculated
}
/**
* Send the updated list of items back to the parent component.
*/
update(items, branch) {
this.props.onUpdate({
branch: branch,
items: items
})
}
/**
* Flip the `open` bit for the item.
*/
toggle(item) {
const items = [...this.props.items].map(x => {
if (x.uuid === item.uuid) {
x.open = !x.open
}
return x
})
if (this.stickyStatus[item.uuid]) {
this.update(items, this.props.branch)
this.setState({ initial: false, scrollToId: item.uuid })
} else {
this.update(items, this.props.branch)
this.setState({ initial: false, scrollToId: '' })
}
}
/**
* Add a new item to the end of the current array of items while setting the
* default states.
*/
add() {
let items = [...this.props.items]
for (let item of items) {
item.open = false
}
const item = this.newItem()
items = items.concat([item])
this.update(items, { value: '' })
this.setState({ initial: false, scrollToId: item.uuid })
}
/**
* Remove the item from the array of items.
*/
remove(item) {
// Confirm deletion first
if (
this.props.skipWarning ||
window.confirm(i18n.t('collection.warning')) === true
) {
let items = [...this.props.items].filter(x => {
return x.uuid !== item.uuid
})
if (items.length < this.props.minimum) {
items.push(this.newItem())
}
this.update(items, { value: '' })
this.setState({ initial: false, scrollToId: '' })
}
}
/**
* Update an item properties based on a child component.
*/
updateChild(item, prop, value) {
let items = [...this.props.items]
const index = items.findIndex(x => x.uuid === item.uuid)
items[index][prop] = value
this.update(items, this.props.branch)
}
/**
* Update the accordion addendum branch value.
*/
updateAddendum(values) {
if (values.value === 'Yes') {
this.add()
return
}
if (this.props.scrollToBottom) {
scrollToBottom(this.props.scrollToBottom)
}
this.update(this.props.items, values)
}
/**
* Clone the component children and provide the associated values based on the item context.
*/
factory(item, index, children) {
return React.Children.map(children, child => {
let childProps = {}
if (React.isValidElement(child)) {
if (child.props.bind) {
childProps = { ...item[child.props.name] }
childProps.onUpdate = value => {
const propName = child.props.name
? child.props.name
: value && value.name
? value.name
: 'Extra'
this.updateChild(item, propName, value)
}
childProps.onError = this.props.onError
// HACK: Manually attaching updated addressBooks because each accordion item
// object is not getting updated with the new addressBooks. The children components
// are getting the updated address book.
if (child.props.addressBooks) {
childProps.addressBooks = child.props.addressBooks
}
}
}
const typeOfChildren = Object.prototype.toString.call(
child.props.children
)
if (
child.props.children &&
['[object Object]', '[object Array]'].includes(typeOfChildren)
) {
childProps.children = this.factory(item, index, child.props.children)
}
return React.cloneElement(child, childProps)
})
}
/**
* Return the appropriate verbiage to use based on the items open state
*/
openText(item = {}) {
return item.open ? this.props.closeLabel : this.props.openLabel
}
/**
* Render the item summary which can be overriden with `customSummary`
*/
summary(item, index, initial = false) {
const closedAndIncomplete =
!item.open && !this.isValid(item.uuid)
const svg = closedAndIncomplete ? (
<Svg
src="/img/exclamation-point.svg"
className="incomplete"
alt={this.props.incomplete}
/>
) : null
return (
<div className="summary-container">
<div className="summary">
<a
href="javascript:;;;"
className={`left ${openState(item, initial)}`}
title={`Click to ${this.openText(item).toLowerCase()} this item`}
onClick={this.toggle.bind(this, item)}>
<span className="button-with-icon" aria-hidden="true">
<i className={chevron(item)} aria-hidden="true" />
<span className="toggle">{this.openText(item)}</span>
</span>
{svg}
{this.props.summary(item, index, initial)}
</a>
<a
href="javascript:;;;"
className="right remove"
aria-label="Remove this item"
title="Remove this item"
onClick={this.remove.bind(this, item)}>
<span className="button-with-icon">
<i className="fa fa-trash" aria-hidden="true" />
<span>{this.props.removeLabel}</span>
</span>
</a>
</div>
<Show when={closedAndIncomplete}>
{this.props.byline(
item,
index,
initial,
this.props.incomplete,
this.props.required
)}
</Show>
</div>
)
}
/**
* Render the item details which can be overriden with `customDetails`
*/
details(item, index, initial = false) {
return (
<div className={`details ${openState(item, initial)}`}>
{this.factory(item, index, this.props.children)}
</div>
)
}
onStickyScroll(item, stick) {
// Set the sticky status for the particular item
this.stickyStatus[item.uuid] = stick
}
/**
* Render the individual items in the array.
*/
content() {
// Ensure we have the minimum amount of items required
const initial = this.state.initial
const items = [...this.props.items]
return items.map((item, index, arr) => {
// Bind for each item so we get a handle to it when we set the sticky status
const onScroll = this.onStickyScroll.bind(this, item)
const isValid = this.hasNoErrors(item.uuid)
const summary = this.props.customSummary(
item,
index,
initial,
() => {
return this.summary(item, index, initial)
},
() => {
return this.toggle.bind(this, item)
},
() => {
return this.openText(item)
},
() => {
return this.remove.bind(this, item)
},
() => {
return this.props.byline(item, index, initial)
},
isValid
)
const details = this.props.customDetails(item, index, initial, () => {
return this.details(item, index, initial)
})
return (
<StickyAccordionSummary
id={item.uuid}
key={item.uuid}
className="item"
stickyClass="sticky-accordion"
onScroll={onScroll}
preventStick={!item.open}>
{summary}
{details}
</StickyAccordionSummary>
)
})
}
/**
* The append button is only displayed if there is no addendum.
*/
appendButton() {
if (this.props.appendTitle || this.props.appendMessage) {
return null
}
return (
<button className="add usa-button-outline" onClick={this.add}>
{this.props.appendLabel}
<i className="fa fa-plus-circle" />
</button>
)
}
/**
* Render the accordion addendum notice
*/
addendum() {
if (!this.props.appendTitle && !this.props.appendMessage) {
return null
}
const klassAppend = `addendum ${this.props.appendClass}`.trim()
return (
<Branch
label={this.props.appendTitle}
labelSize="h4"
className={klassAppend}
help={this.props.appendHelp}
value={(this.props.branch || {}).value}
onUpdate={this.updateAddendum}
onError={this.props.onError}
required={this.props.required}
scrollIntoView={this.props.scrollIntoView}>
{this.props.appendMessage}
</Branch>
)
}
description() {
const ariaOnly = this.props.items.length < 2
return (
<strong className={ariaOnly ? 'aria-description' : ''}>
{this.props.description}
</strong>
)
}
/**
* Render the accordion caption which is essentially a `table-caption`
* for the accordion
*/
caption() {
return this.props.caption ? (
<div className="caption">{this.props.caption(this.props)}</div>
) : null
}
/**
* Determines if given item is valid.
* */
isValid(uuid) {
const { required } = this.props
return required
? this.hasNoErrors(uuid)
: true
}
hasNoErrors(uuid) {
const { errors } = this.props
return errors && errors.filter(e => e.indexOf(uuid) > -1).length < 1
}
render() {
const { gapError } = this.props
const klass = `accordion ${this.props.className}`.trim()
return (
<div ref="accordion">
<div className={klass}>
{this.description()}
{this.caption()}
<div className="items">{this.content()}</div>
<div className="append-button">{this.appendButton()}</div>
</div>
{gapError}
{this.addendum()}
</div>
)
}
}
Accordion.propTypes = {
gapError: PropTypes.node,
}
Accordion.defaultProps = {
initial: true,
skipWarning: false,
minimum: 1,
defaultState: true,
items: [],
branch: { value: '' },
className: '',
appendTitle: '',
appendMessage: null,
appendHelp: null,
appendClass: '',
appendLabel: i18n.t('collection.append'),
openLabel: i18n.t('collection.open'),
closeLabel: i18n.t('collection.close'),
removeLabel: i18n.t('collection.remove'),
description: i18n.t('collection.summary'),
incomplete: i18n.t('collection.incomplete'),
caption: null,
scrollToTop: '',
scrollToBottom: '',
timeout: 500,
sort: null,
realtime: true,
errors: [],
gapError: null,
inject: items => {
return items
},
onUpdate: queue => {},
onError: (value, arr) => {
return arr
},
summary: (item, index, initial = false) => {
return (
<span>
<strong>Warning:</strong> Item summary not implemented
</span>
)
},
transformer: item => {
return item && item.Item ? item.Item : item
},
byline: (item, index, initial = false, message = '') => {
return (
<div className={`byline ${openState(item, initial)} fade in`.trim()}>
<div className="usa-alert usa-alert-error" role="alert">
<div className="usa-alert-body">
<h5 className="usa-alert-heading">{message}</h5>
</div>
</div>
</div>
)
},
customSummary: (
item,
index,
initial,
callback,
toggle,
openText,
remove,
byline,
isValid
) => {
return callback()
},
customDetails: (item, index, initial, callback) => {
return callback()
}
}
Accordion.defaultList = { items: [], branch: {} }