mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-04 18:29:29 +02:00
257 lines
7 KiB
JavaScript
257 lines
7 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
|
|
|
|
"use strict";
|
|
|
|
const {
|
|
Component,
|
|
createElement,
|
|
} = require("resource://devtools/client/shared/vendor/react.js");
|
|
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
|
|
const {
|
|
ul,
|
|
li,
|
|
h2,
|
|
div,
|
|
span,
|
|
} = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
|
|
|
|
class Accordion extends Component {
|
|
static get propTypes() {
|
|
return {
|
|
className: PropTypes.string,
|
|
// A list of all items to be rendered using an Accordion component.
|
|
items: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
buttons: PropTypes.arrayOf(PropTypes.object),
|
|
className: PropTypes.string,
|
|
component: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
|
|
componentProps: PropTypes.object,
|
|
contentClassName: PropTypes.string,
|
|
header: PropTypes.string.isRequired,
|
|
id: PropTypes.string.isRequired,
|
|
onToggle: PropTypes.func,
|
|
// Determines the initial open state of the accordion item
|
|
opened: PropTypes.bool.isRequired,
|
|
// Enables dynamically changing the open state of the accordion
|
|
// on update.
|
|
shouldOpen: PropTypes.func,
|
|
})
|
|
).isRequired,
|
|
};
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
opened: {},
|
|
};
|
|
|
|
this.onHeaderClick = this.onHeaderClick.bind(this);
|
|
this.onHeaderKeyDown = this.onHeaderKeyDown.bind(this);
|
|
this.setInitialState = this.setInitialState.bind(this);
|
|
this.updateCurrentState = this.updateCurrentState.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.setInitialState();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.items !== this.props.items) {
|
|
this.updateCurrentState();
|
|
}
|
|
}
|
|
|
|
setInitialState() {
|
|
/**
|
|
* Add initial data to the `state.opened` map.
|
|
* This happens only on initial mount of the accordion.
|
|
*/
|
|
const newItems = this.props.items.filter(
|
|
({ id }) => typeof this.state.opened[id] !== "boolean"
|
|
);
|
|
|
|
if (newItems.length) {
|
|
const everOpened = { ...this.state.everOpened };
|
|
const opened = { ...this.state.opened };
|
|
for (const item of newItems) {
|
|
everOpened[item.id] = item.opened;
|
|
opened[item.id] = item.opened;
|
|
}
|
|
|
|
this.setState({ everOpened, opened });
|
|
}
|
|
}
|
|
|
|
updateCurrentState() {
|
|
/**
|
|
* Updates the `state.opened` map based on the
|
|
* new items that have been added and those that
|
|
* `item.shouldOpen()` has changed. This happens
|
|
* on each update.
|
|
*/
|
|
const updatedItems = this.props.items.filter(item => {
|
|
const notExist = typeof this.state.opened[item.id] !== "boolean";
|
|
if (typeof item.shouldOpen == "function") {
|
|
const currentState = this.state.opened[item.id];
|
|
return notExist || currentState !== item.shouldOpen(item, currentState);
|
|
}
|
|
return notExist;
|
|
});
|
|
|
|
if (updatedItems.length) {
|
|
const everOpened = { ...this.state.everOpened };
|
|
const opened = { ...this.state.opened };
|
|
for (const item of updatedItems) {
|
|
let itemOpen = item.opened;
|
|
if (typeof item.shouldOpen == "function") {
|
|
itemOpen = item.shouldOpen(item, itemOpen);
|
|
}
|
|
everOpened[item.id] = itemOpen;
|
|
opened[item.id] = itemOpen;
|
|
}
|
|
this.setState({ everOpened, opened });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Event} event Click event.
|
|
* @param {Object} item The item to be collapsed/expanded.
|
|
*/
|
|
onHeaderClick(event, item) {
|
|
event.preventDefault();
|
|
// In the Browser Toolbox's Inspector/Layout view, handleHeaderClick is
|
|
// called twice unless we call stopPropagation, making the accordion item
|
|
// open-and-close or close-and-open
|
|
event.stopPropagation();
|
|
this.toggleItem(item);
|
|
}
|
|
|
|
/**
|
|
* @param {Event} event Keyboard event.
|
|
* @param {Object} item The item to be collapsed/expanded.
|
|
*/
|
|
onHeaderKeyDown(event, item) {
|
|
if (event.key === " " || event.key === "Enter") {
|
|
event.preventDefault();
|
|
this.toggleItem(item);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expand or collapse an accordion list item.
|
|
* @param {Object} item The item to be collapsed or expanded.
|
|
*/
|
|
toggleItem(item) {
|
|
const opened = !this.state.opened[item.id];
|
|
|
|
this.setState({
|
|
everOpened: {
|
|
...this.state.everOpened,
|
|
[item.id]: true,
|
|
},
|
|
opened: {
|
|
...this.state.opened,
|
|
[item.id]: opened,
|
|
},
|
|
});
|
|
|
|
if (typeof item.onToggle === "function") {
|
|
item.onToggle(opened, item);
|
|
}
|
|
}
|
|
|
|
renderItem(item) {
|
|
const {
|
|
buttons,
|
|
className = "",
|
|
component,
|
|
componentProps = {},
|
|
contentClassName = "",
|
|
header,
|
|
id,
|
|
} = item;
|
|
|
|
const headerId = `${id}-header`;
|
|
const opened = this.state.opened[id];
|
|
let itemContent;
|
|
|
|
// Only render content if the accordion item is open or has been opened once before.
|
|
// This saves us rendering complex components when users are keeping
|
|
// them closed (e.g. in Inspector/Layout) or may not open them at all.
|
|
if (this.state.everOpened && this.state.everOpened[id]) {
|
|
if (typeof component === "function") {
|
|
itemContent = createElement(component, componentProps);
|
|
} else if (typeof component === "object") {
|
|
itemContent = component;
|
|
}
|
|
}
|
|
|
|
return li(
|
|
{
|
|
key: id,
|
|
id,
|
|
className: `accordion-item ${
|
|
opened ? "accordion-open" : ""
|
|
} ${className} `.trim(),
|
|
"aria-labelledby": headerId,
|
|
},
|
|
h2(
|
|
{
|
|
id: headerId,
|
|
className: "accordion-header",
|
|
tabIndex: 0,
|
|
"aria-expanded": opened,
|
|
// If the header contains buttons, make sure the heading name only
|
|
// contains the "header" text and not the button text
|
|
"aria-label": header,
|
|
onKeyDown: event => this.onHeaderKeyDown(event, item),
|
|
onClick: event => this.onHeaderClick(event, item),
|
|
},
|
|
span({
|
|
className: `theme-twisty${opened ? " open" : ""}`,
|
|
role: "presentation",
|
|
}),
|
|
span(
|
|
{
|
|
className: "accordion-header-label",
|
|
},
|
|
header
|
|
),
|
|
buttons &&
|
|
span(
|
|
{
|
|
className: "accordion-header-buttons",
|
|
role: "presentation",
|
|
},
|
|
buttons
|
|
)
|
|
),
|
|
div(
|
|
{
|
|
className: `accordion-content ${contentClassName}`.trim(),
|
|
hidden: !opened,
|
|
role: "presentation",
|
|
},
|
|
itemContent
|
|
)
|
|
);
|
|
}
|
|
|
|
render() {
|
|
return ul(
|
|
{
|
|
className:
|
|
"accordion" +
|
|
(this.props.className ? ` ${this.props.className}` : ""),
|
|
tabIndex: -1,
|
|
},
|
|
this.props.items.map(item => this.renderItem(item))
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = Accordion;
|