Fazendo calendários com acessibilidade e internacionalização em mente

Fazendo calendários com acessibilidade e internacionalização em mente

Fazendo uma pesquisa rápida aqui no CSS-Tricks mostra quantas maneiras diferentes existem para abordar os calendários. Alguns mostram como CSS Grid pode criar o layout de forma eficiente. Alguns tentam trazer dados reais para a mistura. Alguns confiar em uma estrutura para ajudar na gestão do estado.

Há muitas considerações ao criar um componente de calendário - muito mais do que o que é abordado nos artigos que vinculei. Se você pensar sobre isso, os calendários são repletos de nuances, desde lidar com fusos horários e formatos de data até localização e até garantir que as datas fluam de um mês para o outro... e isso antes mesmo de entrarmos em acessibilidade e considerações adicionais de layout, dependendo de onde o calendário é exibido e outros enfeites.

Muitos desenvolvedores temem o Date() objeto e ficar com bibliotecas mais antigas como moment.js. Mas, embora existam muitas “pegadinhas” quando se trata de datas e formatação, o JavaScript tem muitas APIs legais e outras coisas para ajudar!

Grade do calendário de janeiro de 2023.
Fazendo calendários com acessibilidade e internacionalização em mente

Não quero recriar a roda aqui, mas mostrarei como podemos obter um calendário muito bom com o JavaScript vanilla. vamos investigar acessibilidade, usando marcação semântica e compatível com o leitor de tela <time> -tags — assim como Internacionalização e formatação, Usando o Intl.Locale, Intl.DateTimeFormat e Intl.NumberFormat-APIs.

Em outras palavras, estamos fazendo um calendário... só que sem as dependências extras que você pode ver normalmente usadas em um tutorial como este e com algumas das nuances que você pode não ver normalmente. E, no processo, espero que você adquira uma nova apreciação pelas coisas mais novas que o JavaScript pode fazer enquanto obtém uma ideia dos tipos de coisas que passam pela minha cabeça quando estou montando algo assim.

Primeiro, nomeando

Como devemos chamar nosso componente de calendário? Na minha língua nativa, seria chamado de “elemento kalender”, então vamos usar isso e abreviar para “Kal-El” — também conhecido como O nome do Superman no planeta Krypton.

Vamos criar uma função para fazer as coisas acontecerem:

function kalEl(settings = {}) { ... }

Este método irá renderizar um único mês. Mais tarde, chamaremos esse método de [...Array(12).keys()] render um ano inteiro.

Dados iniciais e internacionalização

Uma das coisas comuns que um calendário online típico faz é destacar a data atual. Então vamos criar uma referência para isso:

const today = new Date();

Em seguida, criaremos um “objeto de configuração” que mesclaremos com o opcional settings objeto do método primário:

const config = Object.assign( { locale: (document.documentElement.getAttribute('lang') || 'en-US'), today: { day: today.getDate(), month: today.getMonth(), year: today.getFullYear() } }, settings
);

Verificamos se o elemento raiz (<html>) contém um lang-atributo com local informações; caso contrário, voltaremos a usar en-US. Este é o primeiro passo para internacionalizando o calendário.

Também precisamos determinar qual mês exibir inicialmente quando o calendário for renderizado. Por isso estendemos o config objeto com o primário date. Desta forma, se nenhuma data for fornecida no settings objeto, usaremos o today referência em vez disso:

const date = config.date ? new Date(config.date) : today;

Precisamos de um pouco mais de informação para formatar corretamente o calendário com base na localidade. Por exemplo, podemos não saber se o primeiro dia da semana é domingo ou segunda-feira, dependendo da localidade. Se tivermos a informação, ótimo! Mas se não, vamos atualizá-lo usando o Intl.Locale API. A API possui um weekInfo objeto que retorna um firstDay propriedade que nos dá exatamente o que procuramos sem qualquer aborrecimento. Também podemos obter quais dias da semana são atribuídos ao weekend:

if (!config.info) config.info = new Intl.Locale(config.locale).weekInfo || { firstDay: 7, weekend: [6, 7] };

Mais uma vez, criamos fallbacks. O “primeiro dia” da semana para en-US é domingo, então o padrão é um valor de 7. Isso é um pouco confuso, pois o getDay método em JavaScript retorna os dias como [0-6], Onde 0 é domingo... não me pergunte por quê. Os fins de semana são sábado e domingo, portanto [6, 7].

Antes tínhamos o Intl.Locale API e seu weekInfo método, era muito difícil criar um calendário internacional sem muitos **objetos e arrays com informações sobre cada localidade ou região. Hoje em dia, é fácil. Se nós passarmos en-GB, o método retorna:

// en-GB
{ firstDay: 1, weekend: [6, 7], minimalDays: 4
}

Em um país como Brunei (ms-BN), o fim de semana é sexta e domingo:

// ms-BN
{ firstDay: 7, weekend: [5, 7], minimalDays: 1
}

Você pode se perguntar o que isso minimalDays propriedade é. esse é o menor número de dias necessários na primeira semana de um mês para ser contado como uma semana completa. Em algumas regiões, pode ser apenas um dia. Para outros, pode ser sete dias completos.

A seguir, criaremos um render método dentro do nosso kalEl-método:

const render = (date, locale) => { ... }

Ainda precisamos de mais alguns dados para trabalhar antes de renderizar qualquer coisa:

const month = date.getMonth();
const year = date.getFullYear();
const numOfDays = new Date(year, month + 1, 0).getDate();
const renderToday = (year === config.today.year) && (month === config.today.month);

O último é um Boolean que verifica se today existe no mês que estamos prestes a renderizar.

Marcação semântica

Vamos nos aprofundar na renderização em apenas um momento. Mas, primeiro, quero ter certeza de que os detalhes que configuramos têm tags HTML semânticas associadas a eles. Configurar isso imediatamente nos dá benefícios de acessibilidade desde o início.

Invólucro de calendário

Primeiro, temos o wrapper não semântico: <kal-el>. Tudo bem porque não há uma semântica <calendar> etiqueta ou qualquer coisa assim. Se não estivéssemos criando um elemento personalizado, <article> pode ser o elemento mais apropriado, pois o calendário pode ficar em sua própria página.

Nomes dos meses

A <time> O elemento será importante para nós porque ajuda a traduzir as datas em um formato que os leitores de tela e os mecanismos de pesquisa possam analisar com mais precisão e consistência. Por exemplo, veja como podemos transmitir “janeiro de 2023” em nossa marcação:

<time datetime="2023-01">January <i>2023</i></time>

nomes dos dias

A linha acima das datas do calendário contendo os nomes dos dias da semana pode ser complicada. O ideal é que possamos escrever os nomes completos de cada dia — por exemplo, domingo, segunda, terça, etc. — mas isso pode ocupar muito espaço. Então, vamos abreviar os nomes por enquanto dentro de um <ol> onde cada dia é um <li>:

<ol> <li><abbr title="Sunday">Sun</abbr></li> <li><abbr title="Monday">Mon</abbr></li> <!-- etc. -->
</ol>

Podemos ser complicados com CSS para obter o melhor dos dois mundos. Por exemplo, se modificarmos a marcação um pouco assim:

<ol> <li> <abbr title="S">Sunday</abbr> </li>
</ol>

…obtemos os nomes completos por padrão. Podemos então “esconder” o nome completo quando o espaço acabar e exibir o title em vez disso:

@media all and (max-width: 800px) { li abbr::after { content: attr(title); }
}

Mas, não vamos por esse caminho porque o Intl.DateTimeFormat A API também pode ajudar aqui. Abordaremos isso na próxima seção, quando abordarmos a renderização.

Números do dia

Cada data na grade do calendário recebe um número. Cada número é um item de lista (<li>) em uma lista ordenada (<ol>), e a linha <time> marca envolve o número real.

<li> <time datetime="2023-01-01">1</time>
</li>

E embora eu não esteja planejando fazer nenhum estilo ainda, sei que vou querer alguma maneira de estilizar os números da data. Isso é possível como está, mas também quero poder estilizar os números dos dias da semana de maneira diferente dos números do fim de semana, se necessário. Então, vou incluir data-* atributos especificamente para isso: data-weekend e data-today.

Números da semana

Há 52 semanas em um ano, às vezes 53. Embora não seja muito comum, pode ser bom exibir o número de uma determinada semana no calendário para contexto adicional. Eu gosto de tê-lo agora, mesmo que não acabe não usando. Mas vamos usá-lo totalmente neste tutorial.

Usaremos um data-weeknumber atributo como um gancho de estilo e inclua-o na marcação para cada data que for a primeira data da semana.

<li data-day="7" data-weeknumber="1" data-weekend=""> <time datetime="2023-01-08">8</time>
</li>

Rendering

Vamos colocar o calendário em uma página! Nós já sabemos disso <kal-el> é o nome do nosso elemento personalizado. A primeira coisa que precisamos para configurá-lo é definir o firstDay propriedade nele, para que o calendário saiba se domingo ou outro dia é o primeiro dia da semana.

<kal-el data-firstday="${ config.info.firstDay }">

Nós estaremos usando literais de modelo para renderizar a marcação. Para formatar as datas para um público internacional, usaremos o Intl.DateTimeFormat API, novamente usando o locale especificamos anteriormente.

O mês e o ano

Quando chamamos o month, podemos definir se queremos usar o long nome (por exemplo, fevereiro) ou o short nome (por exemplo, fevereiro). Vamos usar o long name, pois é o título acima do calendário:

<time datetime="${year}-${(pad(month))}"> ${new Intl.DateTimeFormat( locale, { month:'long'}).format(date)} <i>${year}</i>
</time>

Nomes dos dias da semana

Para os dias da semana exibidos acima da grade de datas, precisamos dos dois long (por exemplo, “domingo”) e short (abreviado, ou seja, “Sol”) nomes. Dessa forma, podemos usar o nome “curto” quando o calendário estiver com pouco espaço:

Intl.DateTimeFormat([locale], { weekday: 'long' })
Intl.DateTimeFormat([locale], { weekday: 'short' })

Vamos fazer um pequeno método auxiliar que torna um pouco mais fácil chamar cada um:

const weekdays = (firstDay, locale) => { const date = new Date(0); const arr = [...Array(7).keys()].map(i => { date.setDate(5 + i) return { long: new Intl.DateTimeFormat([locale], { weekday: 'long'}).format(date), short: new Intl.DateTimeFormat([locale], { weekday: 'short'}).format(date) } }) for (let i = 0; i < 8 - firstDay; i++) arr.splice(0, 0, arr.pop()); return arr;
}

Veja como invocamos isso no modelo:

<ol> ${weekdays(config.info.firstDay,locale).map(name => ` <li> <abbr title="${name.long}">${name.short}</abbr> </li>`).join('') }
</ol>

Números do dia

E, finalmente, os dias, envoltos em um <ol> elemento:

${[...Array(numOfDays).keys()].map(i => { const cur = new Date(year, month, i + 1); let day = cur.getDay(); if (day === 0) day = 7; const today = renderToday && (config.today.day === i + 1) ? ' data-today':''; return ` <li data-day="${day}"${today}${i === 0 || day === config.info.firstDay ? ` data-weeknumber="${new Intl.NumberFormat(locale).format(getWeek(cur))}"`:''}${config.info.weekend.includes(day) ? ` data-weekend`:''}> <time datetime="${year}-${(pad(month))}-${pad(i)}" tabindex="0"> ${new Intl.NumberFormat(locale).format(i + 1)} </time> </li>`
}).join('')}

Vamos decompô-lo:

  1. Criamos uma matriz “fictícia”, com base na variável “número de dias”, que usaremos para iterar.
  2. Nós criamos um day variável para o dia atual na iteração.
  3. Corrigimos a discrepância entre o Intl.Locale API e getDay().
  4. Se o day é igual a today, nós adicionamos um data-* atributo.
  5. Por fim, devolvemos o <li> elemento como uma string com dados mesclados.
  6. tabindex="0" torna o elemento focalizável, ao usar a navegação pelo teclado, após qualquer valor tabindex positivo (Nota: você deve nunca adicionar positivo valores tabindex)

Para "preencher" os números no datetime atributo, usamos um pequeno método auxiliar:

const pad = (val) => (val + 1).toString().padStart(2, '0');

Número da semana

Novamente, o “número da semana” é onde uma semana cai em um calendário de 52 semanas. Também usamos um pequeno método auxiliar para isso:

function getWeek(cur) { const date = new Date(cur.getTime()); date.setHours(0, 0, 0, 0); date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); const week = new Date(date.getFullYear(), 0, 4); return 1 + Math.round(((date.getTime() - week.getTime()) / 86400000 - 3 + (week.getDay() + 6) % 7) / 7);
}

eu não escrevi isso getWeek-método. É uma versão limpa de este roteiro.

E é isso! graças ao Intl.Locale, Intl.DateTimeFormat e Intl.NumberFormat APIs, agora podemos simplesmente alterar o lang-atributo do <html> elemento para alterar o contexto do calendário com base na região atual:

Grade do calendário de janeiro de 2023.
de-DE
Grade do calendário de janeiro de 2023.
fa-IR
Grade do calendário de janeiro de 2023.
zh-Hans-CN-u-nu-hanidec

Estilizando o calendário

Você deve se lembrar de como todos os dias são apenas um <ol> com itens de lista. Para estilizá-los em um calendário legível, mergulhamos no maravilhoso mundo do CSS Grid. Na verdade, podemos reaproveitar a mesma grade de um modelo de calendário inicial aqui no CSS-Tricks, mas atualizou um pouco com o :is() pseudo relacional para otimizar o código.

Observe que estou definindo variáveis ​​CSS configuráveis ​​ao longo do caminho (e prefixando-as com ---kalel- evitar conflitos).

kal-el :is(ol, ul) { display: grid; font-size: var(--kalel-fz, small); grid-row-gap: var(--kalel-row-gap, .33em); grid-template-columns: var(--kalel-gtc, repeat(7, 1fr)); list-style: none; margin: unset; padding: unset; position: relative;
}
Grade de calendário de sete colunas com linhas de grade mostradas.
Fazendo calendários com acessibilidade e internacionalização em mente

Vamos desenhar bordas ao redor dos números de data para ajudar a separá-los visualmente:

kal-el :is(ol, ul) li { border-color: var(--kalel-li-bdc, hsl(0, 0%, 80%)); border-style: var(--kalel-li-bds, solid); border-width: var(--kalel-li-bdw, 0 0 1px 0); grid-column: var(--kalel-li-gc, initial); text-align: var(--kalel-li-tal, end); }

A grade de sete colunas funciona bem quando o primeiro dia do mês é tb o primeiro dia da semana para o local selecionado). Mas essa é a exceção e não a regra. Na maioria das vezes, precisaremos mudar o primeiro dia do mês para um dia da semana diferente.

Mostrando o primeiro dia do mês caindo em uma quinta-feira.
Fazendo calendários com acessibilidade e internacionalização em mente

Lembre-se de todos os extras data-* atributos que definimos ao escrever nossa marcação? Podemos nos conectar a eles para atualizar qual coluna da grade (--kalel-li-gc) o primeiro número de data do mês é colocado em:

[data-firstday="1"] [data-day="3"]:first-child { --kalel-li-gc: 1 / 4;
}

Nesse caso, estamos expandindo da primeira coluna da grade para a quarta coluna da grade — que irá “empurrar” automaticamente o próximo item (Dia 2) para a quinta coluna da grade e assim por diante.

Vamos adicionar um pouco de estilo à data “atual”, para que ela se destaque. Estes são apenas os meus estilos. Você pode fazer totalmente o que quiser aqui.

[data-today] { --kalel-day-bdrs: 50%; --kalel-day-bg: hsl(0, 86%, 40%); --kalel-day-hover-bgc: hsl(0, 86%, 70%); --kalel-day-c: #fff;
}

Gosto da ideia de estilizar os números de data para fins de semana de maneira diferente dos dias da semana. Vou usar uma cor avermelhada para estilizá-los. Note que podemos alcançar o :not() pseudo-classe para selecioná-los, deixando a data atual sozinha:

[data-weekend]:not([data-today]) { --kalel-day-c: var(--kalel-weekend-c, hsl(0, 86%, 46%));
}

Ah, e não vamos esquecer os números da semana que vêm antes do primeiro número de data de cada semana. Nós usamos um data-weeknumber atributo na marcação para isso, mas os números não serão realmente exibidos, a menos que os revelemos com CSS, o que podemos fazer no ::before pseudo-elemento:

[data-weeknumber]::before { display: var(--kalel-weeknumber-d, inline-block); content: attr(data-weeknumber); position: absolute; inset-inline-start: 0; /* additional styles */
}

Estamos tecnicamente feito neste ponto! Podemos renderizar uma grade de calendário que mostra as datas do mês atual, completa com considerações para localizar os dados por localidade e garantir que o calendário use a semântica adequada. E tudo o que usamos foi JavaScript e CSS vanilla!

Mas vamos pegar isso mais um passo...

Renderizando um ano inteiro

Talvez você precise exibir um ano inteiro de datas! Portanto, em vez de renderizar o mês atual, você pode querer exibir todas as grades mensais do ano atual.

Bem, o bom da abordagem que estamos usando é que podemos chamar o render método quantas vezes quisermos e apenas alterar o inteiro que identifica o mês em cada instância. Vamos chamá-lo 12 vezes com base no ano atual.

tão simples quanto chamar o render-método 12 vezes e apenas altere o número inteiro para month - i:

[...Array(12).keys()].map(i => render( new Date(date.getFullYear(), i, date.getDate()), config.locale, date.getMonth() )
).join('')

Provavelmente é uma boa ideia criar um novo wrapper pai para o ano renderizado. Cada grade de calendário é uma <kal-el> elemento. Vamos chamar o novo wrapper pai <jor-el>, Onde Jor-El é o nome do pai de Kal-El.

<jor-el id="app" data-year="true"> <kal-el data-firstday="7"> <!-- etc. --> </kal-el> <!-- other months -->
</jor-el>

Podemos usar <jor-el> para criar uma grade para nossas grades. Tão meta!

jor-el { background: var(--jorel-bg, none); display: var(--jorel-d, grid); gap: var(--jorel-gap, 2.5rem); grid-template-columns: var(--jorel-gtc, repeat(auto-fill, minmax(320px, 1fr))); padding: var(--jorel-p, 0);
}

Demonstração final

Bônus: calendário de confete

Eu li um excelente livro chamado Fazendo e quebrando a grade outro dia e me deparei com este lindo “poster de Ano Novo”:

Fazendo calendários com acessibilidade e internacionalização em mente PlatoBlockchain Data Intelligence. Pesquisa vertical. Ai.
Fonte: Fazendo e quebrando a grade (2ª edição) por Timothy Samara

Achei que poderíamos fazer algo semelhante sem alterar nada no HTML ou JavaScript. Tomei a liberdade de incluir nomes completos de meses e números em vez de nomes de dias, para torná-lo mais legível. Aproveitar!

Carimbo de hora:

Mais de Truques CSS