Skapa kalendrar med tillgänglighet och internationalisering i åtanke

Skapa kalendrar med tillgänglighet och internationalisering i åtanke

Att göra en snabb sökning här på CSS-Tricks visar hur många olika sätt det finns att närma sig kalendrar. Vissa visar hur CSS Grid kan skapa layouten effektivt. Några försök att ta med faktiska data i mixen. Några förlita sig på ett ramverk att hjälpa till med statlig förvaltning.

Det finns många överväganden när man bygger en kalenderkomponent - mycket mer än vad som tas upp i artiklarna jag länkade till. Om du tänker efter, kalendrar är fyllda med nyanser, från att hantera tidszoner och datumformat till lokalisering och till och med se till att datum flyter från en månad till nästa ... och det är innan vi ens kommer in på tillgänglighet och ytterligare layoutöverväganden beroende på var kalendern är visas och sånt.

Många utvecklare fruktar Date() objektet och håll dig till äldre bibliotek som moment.js. Men även om det finns många "gotchas" när det kommer till datum och formatering, har JavaScript många coola API:er och sånt som hjälper!

Januari 2023 kalenderrutnät.
Skapa kalendrar med tillgänglighet och internationalisering i åtanke

Jag vill inte återskapa hjulet här, men jag ska visa dig hur vi kan få en bra kalender med vanilj JavaScript. Vi ska undersöka tillgänglighet, med semantisk uppmärkning och skärmläsarvänlig <time> -taggar — samt internationalisering och formatering, använda Intl.Locale, Intl.DateTimeFormat och Intl.NumberFormat-API:er.

Med andra ord, vi skapar en kalender... bara utan de extra beroenden som du vanligtvis kan se använda i en självstudie som denna, och med några av de nyanser du kanske inte brukar se. Och i processen hoppas jag att du kommer att få en ny uppskattning för nyare saker som JavaScript kan göra samtidigt som du får en uppfattning om vad jag tänker på när jag sätter ihop något sådant här.

Först och främst, namngivning

Vad ska vi kalla vår kalenderkomponent? På mitt modersmål skulle det kallas "kalenderelement", så låt oss använda det och förkorta det till "Kal-El" - även känt som Stålmannens namn på planeten Krypton.

Låt oss skapa en funktion för att få saker att gå igång:

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

Denna metod kommer att återge en enda månad. Senare kommer vi att kalla denna metod från [...Array(12).keys()] att göra ett helt år.

Initial data och internationalisering

En av de vanligaste sakerna som en vanlig onlinekalender gör är att markera det aktuella datumet. Så låt oss skapa en referens för det:

const today = new Date();

Därefter skapar vi ett "konfigurationsobjekt" som vi slår samman med det valfria settings objektet för den primära metoden:

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

Vi kontrollerar om rotelementet (<html>) innehåller a lang-attribut med locale info; annars går vi tillbaka till att använda en-US. Detta är det första steget mot internationalisering av kalendern.

Vi måste också bestämma vilken månad som ska visas först när kalendern renderas. Det är därför vi utökade config objekt med det primära date. På detta sätt, om inget datum anges i settings objekt kommer vi att använda today referens istället:

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

Vi behöver lite mer information för att korrekt formatera kalendern baserat på språk. Till exempel kanske vi inte vet om den första veckodagen är söndag eller måndag, beroende på plats. Om vi ​​har informationen, bra! Men om inte kommer vi att uppdatera den med hjälp av Intl.Locale API. API:et har en weekInfo objektet som returnerar a firstDay fastighet som ger oss precis vad vi letar efter utan krångel. Vi kan också få vilka dagar i veckan som är tilldelade weekend:

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

Återigen skapar vi fallbacks. Veckans "första dag" för en-US är söndag, så den har som standard ett värde på 7. Detta är lite förvirrande, eftersom getDay metod i JavaScript returnerar dagarna som [0-6]Där 0 är söndag... fråga mig inte varför. Helgerna är alltså lördag och söndag [6, 7].

Innan vi hade Intl.Locale API och dess weekInfo metod var det ganska svårt att skapa en internationell kalender utan många **objekt och arrayer med information om varje lokal eller region. Nuförtiden är det lättsamt. Om vi ​​passerar in en-GB, returnerar metoden:

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

I ett land som Brunei (ms-BN), helgen är fredag ​​och söndag:

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

Du kanske undrar vad det är minimalDays egendom är. Det är minsta antal dagar som krävs under den första veckan i en månad för att räknas som en hel vecka. I vissa regioner kan det bara vara en dag. För andra kan det vara hela sju dagar.

Därefter skapar vi en render metod inom vår kalEl-metod:

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

Vi behöver fortfarande lite mer data att arbeta med innan vi renderar något:

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);

Den sista är en Boolean som kontrollerar om today finns i månaden vi ska återge.

Semantisk uppmärkning

Vi kommer att gå djupare i renderingen på bara ett ögonblick. Men först vill jag se till att detaljerna vi ställer in har semantiska HTML-taggar kopplade till dem. Att ställa in det direkt från början ger oss tillgänglighetsfördelar från början.

Kalenderomslag

Först har vi det icke-semantiska omslaget: <kal-el>. Det är bra eftersom det inte finns en semantik <calendar> tagg eller något liknande. Om vi ​​inte gjorde ett anpassat element, <article> kan vara det mest lämpliga elementet eftersom kalendern kan stå på sin egen sida.

Månadsnamn

Smakämnen <time> element kommer att bli stort för oss eftersom det hjälper till att översätta datum till ett format som skärmläsare och sökmotorer kan analysera mer exakt och konsekvent. Så här kan vi till exempel förmedla "januari 2023" i vår uppmärkning:

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

Dagens namn

Raden ovanför kalenderns datum som innehåller namnen på veckodagarna kan vara knepig. Det är idealiskt om vi kan skriva ut de fullständiga namnen för varje dag — t.ex. söndag, måndag, tisdag, etc. — men det kan ta mycket utrymme. Så låt oss förkorta namnen för nu inuti en <ol> där varje dag är en <li>:

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

Vi kan bli knepiga med CSS för att få det bästa av två världar. Till exempel, om vi modifierade uppmärkningen lite så här:

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

...vi får de fullständiga namnen som standard. Vi kan sedan "gömma" det fullständiga namnet när utrymmet tar slut och visa title attribut istället:

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

Men vi går inte den vägen eftersom Intl.DateTimeFormat API kan också hjälpa till här. Vi kommer till det i nästa avsnitt när vi tar upp rendering.

Dagsnummer

Varje datum i kalenderrutnätet får ett nummer. Varje nummer är ett listobjekt (<li>) i en ordnad lista (<ol>), och inline <time> taggen omsluter det faktiska antalet.

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

Och även om jag inte planerar att göra någon styling än, vet jag att jag kommer att vilja ha något sätt att styla datumsiffrorna på. Det är möjligt som det är, men jag vill också kunna utforma veckodagsnummer annorlunda än helgnummer om jag behöver. Så jag ska ta med data-* attribut speciellt för det: data-weekend och data-today.

Veckonummer

Det finns 52 veckor på ett år, ibland 53. Även om det inte är supervanligt kan det vara trevligt att visa numret för en viss vecka i kalendern för ytterligare sammanhang. Jag gillar att ha den nu, även om jag inte slutar använda den. Men vi kommer att använda det helt i den här handledningen.

Vi använder en data-weeknumber attribut som en stylingkrok och inkludera det i markeringen för varje datum som är veckans första dejt.

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

rendering

Låt oss få kalendern på en sida! Det vet vi redan <kal-el> är namnet på vårt anpassade element. Det första vi behöver konfigurera är att ställa in firstDay egendom på den, så att kalendern vet om söndag eller någon annan dag är den första dagen i veckan.

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

Vi ska använda mall bokstäver för att återge uppmärkningen. För att formatera datumen för en internationell publik använder vi Intl.DateTimeFormat API, återigen med hjälp av locale vi angav tidigare.

Månaden och året

När vi ringer till month, kan vi ställa in om vi vill använda long namn (t.ex. februari) eller short namn (t.ex. feb.). Låt oss använda long namn eftersom det är titeln ovanför kalendern:

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

Namn på veckodagar

För veckodagar som visas ovanför rutnätet med datum behöver vi båda long (t.ex. "söndag") och short (förkortade, dvs. ”Sol”) namn. På så sätt kan vi använda det "korta" namnet när det är ont om plats i kalendern:

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

Låt oss göra en liten hjälpmetod som gör det lite lättare att anropa var och en:

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;
}

Så här åberopar vi det i mallen:

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

Dagsnummer

Och slutligen, dagarna, insvepta i en <ol> element:

${[...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('')}

Låt oss bryta ner det:

  1. Vi skapar en "dummy"-array, baserad på variabeln "antal dagar", som vi använder för att iterera.
  2. Vi skapar en day variabel för den aktuella dagen i iterationen.
  3. Vi fixar avvikelsen mellan Intl.Locale API och getDay().
  4. Om day är lika med today, lägger vi till en data-* attribut.
  5. Slutligen returnerar vi <li> element som en sträng med sammanslagna data.
  6. tabindex="0" gör elementet fokuserbart, när du använder tangentbordsnavigering, efter eventuella positiva tabindex-värden (Obs: du bör aldrig lägga till positiv tabindex-värden)

Till "padda" siffrorna i datetime attribut använder vi en liten hjälpmetod:

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

Veckans nummer

Återigen, "veckans nummer" är där en vecka infaller i en 52-veckors kalender. Vi använder en liten hjälpmetod för det också:

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

Jag skrev inte det här getWeek-metod. Det är en rensad version av detta skript.

Och det är allt! Tack vare Intl.Locale, Intl.DateTimeFormat och Intl.NumberFormat API:er kan vi nu helt enkelt ändra lang-attribut av <html> element för att ändra sammanhanget för kalendern baserat på den aktuella regionen:

Januari 2023 kalenderrutnät.
de-DE
Januari 2023 kalenderrutnät.
fa-IR
Januari 2023 kalenderrutnät.
zh-Hans-CN-u-nu-hanidec

Stylar kalendern

Du kanske minns hur alla dagar bara är en <ol> med listobjekt. För att göra dessa till en läsbar kalender dyker vi in ​​i den underbara världen av CSS Grid. Faktum är att vi kan återanvända samma rutnät från en startkalendermall här på CSS-Tricks, men uppdaterade lite med :is() relationspseudo för att optimera koden.

Lägg märke till att jag definierar konfigurerbara CSS-variabler längs vägen (och prefixer dem med ---kalel- för att undvika konflikter).

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;
}
Kalenderrutnät med sju kolumner med rutnätslinjer som visas.
Skapa kalendrar med tillgänglighet och internationalisering i åtanke

Låt oss rita gränser runt datumnumren för att separera dem visuellt:

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

Rutnätet med sju kolumner fungerar bra när den första dagen i månaden är också första dagen i veckan för den valda lokalen). Men det är undantaget snarare än regeln. Oftast måste vi flytta den första dagen i månaden till en annan veckodag.

Visar den första dagen i månaden som infaller på en torsdag.
Skapa kalendrar med tillgänglighet och internationalisering i åtanke

Kom ihåg allt det extra data-* attribut vi definierade när vi skrev vår uppmärkning? Vi kan koppla in dem för att uppdatera vilken rutkolumn (--kalel-li-gc) månadens första datumnummer placeras på:

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

I det här fallet spänner vi från den första rutkolumnen till den fjärde rutkolumnen - som automatiskt kommer att "skjuta" nästa objekt (dag 2) till den femte rutkolumnen, och så vidare.

Låt oss lägga till lite stil till det "aktuella" datumet, så att det sticker ut. Det här är bara mina stilar. Du kan göra precis vad du vill här.

[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;
}

Jag gillar tanken på att styla datumnummer för helger annorlunda än vardagar. Jag ska använda en rödaktig färg för att styla dem. Observera att vi kan nå för :not() pseudoklass för att välja dem samtidigt som det aktuella datumet lämnas ifred:

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

Åh, och låt oss inte glömma veckonumren som går före det första datumnumret för varje vecka. Vi använde en data-weeknumber attribut i uppmärkningen för det, men siffrorna visas faktiskt inte om vi inte avslöjar dem med CSS, vilket vi kan göra på ::before pseudoelement:

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

Vi är tekniskt klara vid det här laget! Vi kan rendera ett kalenderrutnät som visar datumen för den aktuella månaden, komplett med överväganden för att lokalisera data efter språk, och se till att kalendern använder korrekt semantik. Och allt vi använde var vanilj JavaScript och CSS!

Men låt oss ta det här ett steg till.

Återger ett helt år

Kanske behöver du visa ett helt år med datum! Så istället för att återge den aktuella månaden, kanske du vill visa alla månadsrutnät för det aktuella året.

Tja, det fina med tillvägagångssättet vi använder är att vi kan kalla för render metod så många gånger vi vill och bara ändra heltal som identifierar månaden för varje instans. Låt oss kalla det 12 gånger baserat på innevarande år.

så enkelt som att ringa till render-metod 12 gånger, och ändra bara heltal för month - i:

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

Det är förmodligen en bra idé att skapa ett nytt föräldraomslag för det återgivna året. Varje kalenderrutnät är en <kal-el> element. Låt oss kalla det nya föräldraomslaget <jor-el>Där Jor-El är namnet på Kal-Els far.

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

Vi kan använda <jor-el> att skapa ett rutnät för våra nät. Så 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);
}

Sista demo

Bonus: Konfettikalender

Jag läste en utmärkt bok som heter Skapa och bryta rutnätet häromdagen och snubblade på denna vackra "nyårsaffisch":

Skapa kalendrar med tillgänglighet och internationalisering i minnet PlatoBlockchain Data Intelligence. Vertikal sökning. Ai.
Källa: Making and Breaking the Grid (2nd Edition) av Timothy Samara

Jag tänkte att vi kunde göra något liknande utan att ändra något i HTML eller JavaScript. Jag har tagit mig friheten att inkludera fullständiga namn i månader, och siffror istället för dagnamn, för att göra det mer läsbart. Njut av!

Tidsstämpel:

Mer från CSS-tricks