diff options
Diffstat (limited to 'toolkit/content/widgets/named-deck.js')
-rw-r--r-- | toolkit/content/widgets/named-deck.js | 398 |
1 files changed, 398 insertions, 0 deletions
diff --git a/toolkit/content/widgets/named-deck.js b/toolkit/content/widgets/named-deck.js new file mode 100644 index 0000000000..6b3b7a8835 --- /dev/null +++ b/toolkit/content/widgets/named-deck.js @@ -0,0 +1,398 @@ +/* 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"; + +// This is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + /** + * This element is for use with the <named-deck> element. Set the target + * <named-deck>'s ID in the "deck" attribute and the button's selected state + * will reflect the deck's state. When the button is clicked, it will set the + * view in the <named-deck> to the button's "name" attribute. + * + * The "tab" role will be added unless a different role is provided. Wrapping + * a set of these buttons in a <button-group> element will add the key handling + * for a tablist. + * + * NOTE: This does not observe changes to the "deck" or "name" attributes, so + * changing them likely won't work properly. + * + * <button is="named-deck-button" deck="pet-deck" name="dogs">Dogs</button> + * <named-deck id="pet-deck"> + * <p name="cats">I like cats.</p> + * <p name="dogs">I like dogs.</p> + * </named-deck> + * + * let btn = document.querySelector('button[name="dogs"]'); + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; + * btn.selected == false; // Selected was pulled from the related deck. + * btn.click(); + * deck.selectedViewName == "dogs"; + * btn.selected == true; // Selected updated when view changed. + */ + class NamedDeckButton extends HTMLButtonElement { + connectedCallback() { + this.id = `${this.deckId}-button-${this.name}`; + if (!this.hasAttribute("role")) { + this.setAttribute("role", "tab"); + } + this.setSelectedFromDeck(); + this.addEventListener("click", this); + this.getRootNode().addEventListener("view-changed", this, { + capture: true, + }); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + this.getRootNode().removeEventListener("view-changed", this, { + capture: true, + }); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "selected") { + this.selected = newVal; + } + } + + get deckId() { + return this.getAttribute("deck"); + } + + set deckId(val) { + this.setAttribute("deck", val); + } + + get deck() { + return this.getRootNode().querySelector(`#${this.deckId}`); + } + + handleEvent(e) { + if (e.type == "view-changed" && e.target.id == this.deckId) { + this.setSelectedFromDeck(); + } else if (e.type == "click") { + let { deck } = this; + if (deck) { + deck.selectedViewName = this.name; + } + } + } + + get name() { + return this.getAttribute("name"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + set selected(val) { + if (this.selected != val) { + this.toggleAttribute("selected", val); + } + this.setAttribute("aria-selected", !!val); + } + + setSelectedFromDeck() { + let { deck } = this; + this.selected = deck && deck.selectedViewName == this.name; + if (this.selected) { + this.dispatchEvent( + new CustomEvent("button-group:selected", { bubbles: true }) + ); + } + } + } + customElements.define("named-deck-button", NamedDeckButton, { + extends: "button", + }); + + class ButtonGroup extends HTMLElement { + static get observedAttributes() { + return ["orientation"]; + } + + connectedCallback() { + this.setAttribute("role", "tablist"); + + if (!this.observer) { + this.observer = new MutationObserver(changes => { + for (let change of changes) { + this.setChildAttributes(change.addedNodes); + for (let node of change.removedNodes) { + if (this.activeChild == node) { + // Ensure there's still an active child. + this.activeChild = this.firstElementChild; + } + } + } + }); + } + this.observer.observe(this, { childList: true }); + + // Set the role and tabindex for the current children. + this.setChildAttributes(this.children); + + // Try assigning the active child again, this will run through the checks + // to ensure it's still valid. + this.activeChild = this._activeChild; + + this.addEventListener("button-group:selected", this); + this.addEventListener("keydown", this); + this.addEventListener("mousedown", this); + this.getRootNode().addEventListener("keypress", this); + } + + disconnectedCallback() { + this.observer.disconnect(); + this.removeEventListener("button-group:selected", this); + this.removeEventListener("keydown", this); + this.removeEventListener("mousedown", this); + this.getRootNode().removeEventListener("keypress", this); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "orientation") { + if (this.isVertical) { + this.setAttribute("aria-orientation", this.orientation); + } else { + this.removeAttribute("aria-orientation"); + } + } + } + + setChildAttributes(nodes) { + for (let node of nodes) { + if (node.nodeType == Node.ELEMENT_NODE && node != this.activeChild) { + node.setAttribute("tabindex", "-1"); + } + } + } + + // The activeChild is the child that can be focused with tab. + get activeChild() { + return this._activeChild; + } + + set activeChild(node) { + let prevActiveChild = this._activeChild; + let newActiveChild; + + if (node && this.contains(node)) { + newActiveChild = node; + } else { + newActiveChild = this.firstElementChild; + } + + this._activeChild = newActiveChild; + + if (newActiveChild) { + newActiveChild.setAttribute("tabindex", "0"); + } + + if (prevActiveChild && prevActiveChild != newActiveChild) { + prevActiveChild.setAttribute("tabindex", "-1"); + } + } + + get isVertical() { + return this.orientation == "vertical"; + } + + get orientation() { + return this.getAttribute("orientation") == "vertical" + ? "vertical" + : "horizontal"; + } + + set orientation(val) { + if (val == "vertical") { + this.setAttribute("orientation", val); + } else { + this.removeAttribute("orientation"); + } + } + + _navigationKeys() { + if (this.isVertical) { + return { + previousKey: "ArrowUp", + nextKey: "ArrowDown", + }; + } + if (document.dir == "rtl") { + return { + previousKey: "ArrowRight", + nextKey: "ArrowLeft", + }; + } + return { + previousKey: "ArrowLeft", + nextKey: "ArrowRight", + }; + } + + handleEvent(e) { + let { previousKey, nextKey } = this._navigationKeys(); + if (e.type == "keydown" && (e.key == previousKey || e.key == nextKey)) { + this.setAttribute("last-input-type", "keyboard"); + e.preventDefault(); + let oldFocus = this.activeChild; + this.walker.currentNode = oldFocus; + let newFocus; + if (e.key == previousKey) { + newFocus = this.walker.previousNode(); + } else { + newFocus = this.walker.nextNode(); + } + if (newFocus) { + this.activeChild = newFocus; + this.dispatchEvent(new CustomEvent("button-group:key-selected")); + } + } else if (e.type == "button-group:selected") { + this.activeChild = e.target; + } else if (e.type == "mousedown") { + this.setAttribute("last-input-type", "mouse"); + } else if (e.type == "keypress" && e.key == "Tab") { + this.setAttribute("last-input-type", "keyboard"); + } + } + + get walker() { + if (!this._walker) { + this._walker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + if (node.hidden || node.disabled) { + return NodeFilter.FILTER_REJECT; + } + node.focus(); + return this.getRootNode().activeElement == node + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + } + ); + } + return this._walker; + } + } + customElements.define("button-group", ButtonGroup); + + /** + * A deck that is indexed by the "name" attribute of its children. The + * <named-deck-button> element is a companion element that can update its state + * and change the view of a <named-deck>. + * + * When the deck is connected it will set the first child as the selected view + * if a view is not already selected. + * + * The deck is implemented using a named slot. Setting a slot directly on a + * child element of the deck is not supported. + * + * You can get or set the selected view by name with the `selectedViewName` + * property or by setting the "selected-view" attribute. + * + * <named-deck> + * <section name="cats">Some info about cats.</section> + * <section name="dogs">Some dog stuff.</section> + * </named-deck> + * + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * deck.selectedViewName = "dogs"; + * deck.selectedViewName == "dogs"; // Dog stuff is shown. + * deck.setAttribute("selected-view", "cats"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * + * Add the is-tabbed attribute to <named-deck> if you want + * each of its children to have a tabpanel role and aria-labelledby + * referencing the NamedDeckButton component. + */ + class NamedDeck extends HTMLElement { + static get observedAttributes() { + return ["selected-view"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + // Create a slot for the visible content. + let selectedSlot = document.createElement("slot"); + selectedSlot.setAttribute("name", "selected"); + this.shadowRoot.appendChild(selectedSlot); + + this.observer = new MutationObserver(() => { + this._setSelectedViewAttributes(); + }); + } + + connectedCallback() { + if (this.selectedViewName) { + // Make sure the selected view is shown. + this._setSelectedViewAttributes(); + } else { + // If there's no selected view, default to the first. + let firstView = this.firstElementChild; + if (firstView) { + // This will trigger showing the first view. + this.selectedViewName = firstView.getAttribute("name"); + } + } + this.observer.observe(this, { childList: true }); + } + + disconnectedCallback() { + this.observer.disconnect(); + } + + attributeChangedCallback(attr, oldVal, newVal) { + if (attr == "selected-view" && oldVal != newVal) { + // Update the slot attribute on the views. + this._setSelectedViewAttributes(); + + // Notify that the selected view changed. + this.dispatchEvent(new CustomEvent("view-changed")); + } + } + + get selectedViewName() { + return this.getAttribute("selected-view"); + } + + set selectedViewName(name) { + this.setAttribute("selected-view", name); + } + + /** + * Set the slot attribute on all of the views to ensure only the selected view + * is shown. + */ + _setSelectedViewAttributes() { + let { selectedViewName } = this; + for (let view of this.children) { + let name = view.getAttribute("name"); + + if (this.hasAttribute("is-tabbed")) { + view.setAttribute("aria-labelledby", `${this.id}-button-${name}`); + view.setAttribute("role", "tabpanel"); + } + + if (name === selectedViewName) { + view.slot = "selected"; + } else { + view.slot = ""; + } + } + } + } + customElements.define("named-deck", NamedDeck); +} |