Resumo
<howto-tabs>
limita o conteúdo visível separando-o em vários painéis. Apenas
um painel fica visível por vez, enquanto todas as guias correspondentes estão sempre
visíveis. Para alternar de um painel para outro, a guia correspondente precisa ser
selecionada.
Ao clicar ou usar as teclas de seta, o usuário pode mudar a seleção da guia ativa.
Se o JavaScript estiver desativado, todos os painéis serão mostrados intercalados com as respectivas guias. As guias agora funcionam como títulos.
Referência
Demonstração
Conferir a demonstração ao vivo no GitHub
Exemplo de uso
<style>
howto-tab {
border: 1px solid black;
padding: 20px;
}
howto-panel {
padding: 20px;
background-color: lightgray;
}
howto-tab[selected] {
background-color: bisque;
}
Se o JavaScript não for executado, o elemento não vai corresponder a :defined
. Nesse caso, esse estilo adiciona espaçamento entre as guias e o painel anterior.
howto-tabs:not(:defined), howto-tab:not(:defined), howto-panel:not(:defined) {
display: block;
}
</style>
<howto-tabs>
<howto-tab role="heading" slot="tab">Tab 1</howto-tab>
<howto-panel role="region" slot="panel">Content 1</howto-panel>
<howto-tab role="heading" slot="tab">Tab 2</howto-tab>
<howto-panel role="region" slot="panel">Content 2</howto-panel>
<howto-tab role="heading" slot="tab">Tab 3</howto-tab>
<howto-panel role="region" slot="panel">Content 3</howto-panel>
</howto-tabs>
Código
(function() {
Define códigos de tecla para ajudar a processar eventos de teclado.
const KEYCODE = {
DOWN: 40,
LEFT: 37,
RIGHT: 39,
UP: 38,
HOME: 36,
END: 35,
};
Para evitar a invocação do analisador com .innerHTML
para cada nova instância, um modelo para o conteúdo do DOM sombra é compartilhado por todas as instâncias <howto-tabs>
.
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: flex;
flex-wrap: wrap;
}
::slotted(howto-panel) {
flex-basis: 100%;
}
</style>
<slot name="tab"></slot>
<slot name="panel"></slot>
`;
O elemento "HowtoTabs" é um elemento de contêiner para guias e painéis.
Todos os filhos de <howto-tabs>
precisam ser <howto-tab>
ou <howto-tabpanel>
. Esse elemento é sem estado, o que significa que nenhum valor é armazenado em cache e, portanto, muda durante o trabalho de execução.
class HowtoTabs extends HTMLElement {
constructor() {
super();
Os manipuladores de eventos que não estão conectados a esse elemento precisam ser vinculados se precisarem de acesso a this
.
this._onSlotChange = this._onSlotChange.bind(this);
Para o aprimoramento progressivo, a marcação precisa alternar entre guias e painéis. Elementos que reordenam os filhos tendem a não funcionar bem com frameworks. Em vez disso, o shadow DOM é usado para reordenar os elementos usando slots.
this.attachShadow({ mode: 'open' });
Importe o modelo compartilhado para criar os slots de guias e painéis.
this.shadowRoot.appendChild(template.content.cloneNode(true));
this._tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
this._panelSlot = this.shadowRoot.querySelector('slot[name=panel]');
Esse elemento precisa reagir a novos filhos, pois vincula semanticamente as guias e o painel usando aria-labelledby
e aria-controls
. Os novos filhos vão ser alocados automaticamente e causar o disparo de slotchange, então não é necessário MutationObserver.
this._tabSlot.addEventListener('slotchange', this._onSlotChange);
this._panelSlot.addEventListener('slotchange', this._onSlotChange);
}
connectedCallback()
agrupa guias e painéis reordenando-os e garante que apenas uma guia esteja ativa.
connectedCallback() {
O elemento precisa fazer algum tratamento manual de eventos de entrada para permitir a troca com as teclas de seta e Home / End.
this.addEventListener('keydown', this._onKeyDown);
this.addEventListener('click', this._onClick);
if (!this.hasAttribute('role'))
this.setAttribute('role', 'tablist');
Até recentemente, os eventos slotchange
não eram acionados quando um elemento era atualizado pelo analisador. Por isso, o elemento invoca o gerenciador manualmente. Quando o novo comportamento for lançado em todos os navegadores, o código abaixo poderá ser removido.
Promise.all([
customElements.whenDefined('howto-tab'),
customElements.whenDefined('howto-panel'),
])
.then(() => this._linkPanels());
}
disconnectedCallback()
remove os listeners de eventos que connectedCallback()
adicionou.
disconnectedCallback() {
this.removeEventListener('keydown', this._onKeyDown);
this.removeEventListener('click', this._onClick);
}
_onSlotChange()
é chamado sempre que um elemento é adicionado ou removido de um dos slots do shadow DOM.
_onSlotChange() {
this._linkPanels();
}
_linkPanels()
vincula as guias aos painéis adjacentes usando aria-controls e aria-labelledby
. Além disso, o método garante que apenas uma guia esteja ativa.
_linkPanels() {
const tabs = this._allTabs();
Dê a cada painel um atributo aria-labelledby
que se refira à guia que o controla.
tabs.forEach((tab) => {
const panel = tab.nextElementSibling;
if (panel.tagName.toLowerCase() !== 'howto-panel') {
console.error(`Tab #${tab.id} is not a` +
`sibling of a <howto-panel>`);
return;
}
tab.setAttribute('aria-controls', panel.id);
panel.setAttribute('aria-labelledby', tab.id);
});
O elemento verifica se alguma das guias foi marcada como selecionada. Caso contrário, a primeira guia será selecionada.
const selectedTab =
tabs.find((tab) => tab.selected) || tabs[0];
Em seguida, mude para a guia selecionada. _selectTab()
marca todas as outras guias como desmarcadas e oculta todos os outros painéis.
this._selectTab(selectedTab);
}
_allPanels()
retorna todos os painéis no painel de guias. Essa função pode memorizar o resultado se as consultas do DOM se tornarem um problema de desempenho. A desvantagem da memorização é que as guias e os painéis adicionados dinamicamente não serão processados.
Esse é um método, e não um getter, porque um getter implica que a leitura é barata.
_allPanels() {
return Array.from(this.querySelectorAll('howto-panel'));
}
_allTabs()
retorna todas as guias no painel de guias.
_allTabs() {
return Array.from(this.querySelectorAll('howto-tab'));
}
_panelForTab()
retorna o painel que a guia controla.
_panelForTab(tab) {
const panelId = tab.getAttribute('aria-controls');
return this.querySelector(`#${panelId}`);
}
_prevTab()
retorna a guia que vem antes da selecionada no momento, retornando ao início quando chega à primeira.
_prevTab() {
const tabs = this._allTabs();
Use findIndex()
para encontrar o índice do elemento selecionado no momento e subtrair um para receber o índice do elemento anterior.
let newIdx = tabs.findIndex((tab) => tab.selected) - 1;
Adicione tabs.length
para garantir que o índice seja um número positivo e faça com que o módulo seja transferido, se necessário.
return tabs[(newIdx + tabs.length) % tabs.length];
}
_firstTab()
retorna a primeira guia.
_firstTab() {
const tabs = this._allTabs();
return tabs[0];
}
_lastTab()
retorna a última guia.
_lastTab() {
const tabs = this._allTabs();
return tabs[tabs.length - 1];
}
_nextTab()
recebe a guia que vem depois da selecionada no momento, retornando ao início quando chega à última.
_nextTab() {
const tabs = this._allTabs();
let newIdx = tabs.findIndex((tab) => tab.selected) + 1;
return tabs[newIdx % tabs.length];
}
reset()
marca todas as guias como desmarcadas e oculta todos os painéis.
reset() {
const tabs = this._allTabs();
const panels = this._allPanels();
tabs.forEach((tab) => tab.selected = false);
panels.forEach((panel) => panel.hidden = true);
}
_selectTab()
marca a guia especificada como selecionada. Além disso, ele mostra o painel correspondente à guia.
_selectTab(newTab) {
Desmarque todas as guias e oculte todos os painéis.
this.reset();
Receba o painel ao qual o newTab
está associado.
const newPanel = this._panelForTab(newTab);
Se esse painel não existir, interrompa.
if (!newPanel)
throw new Error(`No panel with id ${newPanelId}`);
newTab.selected = true;
newPanel.hidden = false;
newTab.focus();
}
_onKeyDown()
processa pressionamentos de tecla no painel de guias.
_onKeyDown(event) {
Se a tecla não tiver origem em um elemento de guia, ela foi pressionada dentro de um painel ou em um espaço vazio. Nada a fazer.
if (event.target.getAttribute('role') !== 'tab')
return;
Não processa atalhos de modificador normalmente usados por tecnologia adaptativa.
if (event.altKey)
return;
O switch-case vai determinar qual guia será marcada como ativa, dependendo da tecla pressionada.
let newTab;
switch (event.keyCode) {
case KEYCODE.LEFT:
case KEYCODE.UP:
newTab = this._prevTab();
break;
case KEYCODE.RIGHT:
case KEYCODE.DOWN:
newTab = this._nextTab();
break;
case KEYCODE.HOME:
newTab = this._firstTab();
break;
case KEYCODE.END:
newTab = this._lastTab();
break;
Qualquer outra tecla pressionada é ignorada e transmitida de volta ao navegador.
default:
return;
}
O navegador pode ter alguma funcionalidade nativa vinculada às teclas de seta, "Home" ou "End". O elemento chama preventDefault()
para impedir que o navegador realize qualquer ação.
event.preventDefault();
Selecione a nova guia determinada no caso de comutação.
this._selectTab(newTab);
}
_onClick()
processa cliques no painel de guias.
_onClick(event) {
Se o clique não foi direcionado a um elemento de guia, foi um clique dentro de um painel ou em um espaço vazio. Nada a fazer.
if (event.target.getAttribute('role') !== 'tab')
return;
Se ele estiver em um elemento de guia, selecione essa guia.
this._selectTab(event.target);
}
}
customElements.define('howto-tabs', HowtoTabs);
howtoTabCounter
conta o número de instâncias <howto-tab>
criadas. O número é usado para gerar IDs novos e exclusivos.
let howtoTabCounter = 0;
HowtoTab
é uma guia para um painel de guias <howto-tabs>
. O <howto-tab>
sempre deve ser usado com role="heading"
na marcação para que a semântica continue utilizável quando o JavaScript falhar.
Um <howto-tab>
declara a qual <howto-panel>
ele pertence usando o ID do painel como o valor do atributo aria-controls.
Um <howto-tab>
vai gerar automaticamente um ID exclusivo se nenhum for especificado.
class HowtoTab extends HTMLElement {
static get observedAttributes() {
return ['selected'];
}
constructor() {
super();
}
connectedCallback() {
Se isso for executado, o JavaScript estará funcionando e o elemento mudará de função para tab
.
this.setAttribute('role', 'tab');
if (!this.id)
this.id = `howto-tab-generated-${howtoTabCounter++}`;
Defina um estado inicial bem definido.
this.setAttribute('aria-selected', 'false');
this.setAttribute('tabindex', -1);
this._upgradeProperty('selected');
}
Verifica se uma propriedade tem um valor de instância. Se sim, copie o valor e exclua a propriedade de instância para que ela não ofusque o setter de propriedade da classe. Por fim, transmita o valor para o setter de propriedade de classe para que ele possa acionar os efeitos colaterais. Isso serve para proteger contra casos em que, por exemplo, um framework pode ter adicionado o elemento à página e definido um valor em uma das propriedades, mas carregado a definição de forma lenta. Sem essa proteção, o elemento atualizado perderia essa propriedade, e a propriedade de instância impediria que o setter de propriedade de classe fosse chamado.
_upgradeProperty(prop) {
if (this.hasOwnProperty(prop)) {
let value = this[prop];
delete this[prop];
this[prop] = value;
}
}
As propriedades e os atributos correspondentes precisam ser espelhados. Para isso, o setter de propriedade de selected
processa valores verdadeiros/falsos e os reflete no estado do atributo. É importante observar que não há efeitos colaterais no setter de propriedade. Por exemplo, o setter não define aria-selected
. Em vez disso, esse trabalho acontece no attributeChangedCallback
. Como regra geral, torne os setters de propriedade muito simples e, se a definição de uma propriedade ou atributo causar um efeito colateral (como definir um atributo ARIA correspondente), faça isso no attributeChangedCallback()
. Isso evita a necessidade de gerenciar cenários complexos de reentrada de atributo/propriedade.
attributeChangedCallback() {
const value = this.hasAttribute('selected');
this.setAttribute('aria-selected', value);
this.setAttribute('tabindex', value ? 0 : -1);
}
set selected(value) {
value = Boolean(value);
if (value)
this.setAttribute('selected', '');
else
this.removeAttribute('selected');
}
get selected() {
return this.hasAttribute('selected');
}
}
customElements.define('howto-tab', HowtoTab);
let howtoPanelCounter = 0;
HowtoPanel
é um painel para um painel de guias <howto-tabs>
.
class HowtoPanel extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.setAttribute('role', 'tabpanel');
if (!this.id)
this.id = `howto-panel-generated-${howtoPanelCounter++}`;
}
}
customElements.define('howto-panel', HowtoPanel);
})();