Source code

Revision control

Copy as Markdown

Other Tools

/* 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) {
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);
}