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