制作日历时考虑到可访问性和国际化

制作日历时考虑到可访问性和国际化

在 CSS-Tricks 上快速搜索一下,就会发现有多少种不同的方法可以处理日历。 有些显示如何 CSS Grid 可以高效地创建布局. 有些人试图 将实际数据纳入组合。 一些 依赖一个框架 帮助状态管理。

构建日历组件时有很多注意事项——远远超过我链接的文章中涵盖的内容。 如果您考虑一下,日历充满了细微差别,从处理时区和日期格式到本地化,甚至确保日期从一个月流到下一个月……而这甚至在我们根据日历的位置进入可访问性和其他布局考虑之前显示等等。

许多开发商担心 Date() 对象 并坚持使用旧库,例如 moment.js. 但是,尽管在日期和格式方面有很多“陷阱”,JavaScript 有很多很酷的 API 和东西可以提供帮助!

2023 年 XNUMX 月日历网格。
制作日历时考虑到可访问性和国际化

我不想在这里重新创建轮子,但我将向您展示我们如何使用 vanilla JavaScript 获得一个非常好的日历。 我们会调查 访问,使用语义标记和屏幕阅读器友好 <time> -标签——以及 国际化格式, 使用 Intl.Locale, Intl.DateTimeFormatIntl.NumberFormat-蜜蜂。

换句话说,我们正在制作一个日历……只是没有您通常会在像这样的教程中看到的额外依赖项,并且有一些您通常看不到的细微差别。 而且,在这个过程中,我希望你能对 JavaScript 可以做的新事物有新的认识,同时了解当我将这样的东西放在一起时我脑海中闪过的各种想法。

首先,命名

我们应该怎么称呼我们的日历组件? 在我的母语中,它会被称为“kalender element”,所以让我们使用它并将其缩短为“Kal-El”——也被称为 超人在氪星上的名字.

让我们创建一个函数来让事情继续下去:

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

此方法将呈现 一个月. 稍后我们将从 [...Array(12).keys()] 渲染一整年。

初始数据和国际化

典型的在线日历所做的一项常见操作是突出显示当前日期。 因此,让我们为此创建一个参考:

const today = new Date();

接下来,我们将创建一个“配置对象”,我们将与可选的合并 settings 主要方法的对象:

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

我们检查,如果根元素 (<html>) 包含一个 lang-属性与 当地 信息; 否则,我们将回退到使用 en-US. 这是迈向的第一步 日历国际化.

我们还需要确定在呈现日历时最初显示哪个月份。 这就是为什么我们延长了 config 对象与主要 date. 这样,如果在 settings 对象,我们将使用 today 参考:

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

我们需要更多信息才能根据语言环境正确格式化日历。 例如,我们可能不知道一周的第一天是星期天还是星期一,这取决于语言环境。 如果我们有信息,那就太好了! 但如果没有,我们将使用 Intl.Locale API. API有一个 weekInfo 对象 返回一个 firstDay 可以毫不费力地为我们提供我们正在寻找的东西的财产。 我们还可以获得一周中的哪几天分配给 weekend:

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

同样,我们创建回退。 一周的“第一天” en-US 是星期日,所以它的默认值为 7. 这有点令人困惑,因为 getDay 方法 在 JavaScript 中返回日期为 [0-6],其中 0 今天是星期天……别问我为什么。 周末是周六和周日,因此 [6, 7].

在我们拥有 Intl.Locale API 及其 weekInfo 方法,如果没有许多 **对象和包含每个语言环境或区域信息的数组,很难创建一个国际日历。 如今,这很容易。 如果我们传入 en-GB,该方法返回:

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

在文莱这样的国家(ms-BN), 周末为周五和周日:

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

你可能想知道那是什么 minimalDays 财产群岛那就是 一个月的第一周算作整周所需的最少天数. 在某些地区,可能只有一天。 对于其他人来说,这可能是整整七天。

接下来,我们将创建一个 render 我们的方法 kalEl-方法:

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

在我们渲染任何东西之前,我们仍然需要更多的数据来处理:

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

最后一个是 Boolean 检查是否 today 存在于我们即将呈现的月份。

语义标记

稍后我们将深入了解渲染。 但首先,我想确保我们设置的细节有与之关联的语义 HTML 标签。 开箱即用的设置从一开始就为我们提供了可访问性优势。

日历包装

首先,我们有非语义包装器: <kal-el>. 这很好,因为没有语义 <calendar> 标签或类似的东西。 如果我们不制作自定义元素, <article> 可能是最合适的元素,因为日历可以放在自己的页面上。

月份名称

<time> 元素对我们来说将是一个重要的元素,因为它有助于将日期转换为屏幕阅读器和搜索引擎可以更准确、更一致地解析的格式。 例如,以下是我们如何在标记中传达“2023 年 XNUMX 月”:

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

日期名称

包含星期几名称的日历日期上方的行可能很棘手。 如果我们能写出每一天的全名——例如星期天、星期一、星期二等——那是最理想的——但这会占用很多空间。 所以,让我们现在在一个 <ol> 每一天都是 <li>:

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

我们可以使用 CSS 来获得两全其美的技巧。 例如,如果我们像这样修改标记:

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

…我们默认获取全名。 然后我们可以在空间用完时“隐藏”全名并显示 title 属性代替:

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

但是,我们不会那样做,因为 Intl.DateTimeFormat API 在这里也可以提供帮助。 我们将在下一节介绍渲染时谈到这一点。

日数

日历网格中的每个日期都有一个数字。 每个数字都是一个列表项(<li>) 在有序列表 (<ol>), 和内联 <time> 标签包装实际数字。

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

虽然我现在还不打算做任何样式,但我知道我需要一些方法来设置日期数字的样式。 这是可能的,但如果需要的话,我也希望能够以不同于周末数字的方式设置工作日数字的样式。 所以,我要包括 data-* 属性 专门为此: data-weekenddata-today.

周数

一年有 52 周,有时是 53 周。虽然这不是很常见,但最好在日历中显示给定周的数字以获取更多上下文。 我喜欢现在拥有它,即使我最终不会使用它。 但我们将在本教程中完全使用它。

我们将使用一个 data-weeknumber 属性作为样式挂钩,并将其包含在每个日期(即一周的第一个日期)的标记中。

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

渲染

让我们把日历放在一页上! 我们已经知道 <kal-el> 是我们自定义元素的名称。 我们需要配置的第一件事是设置 firstDay 它的属性,因此日历知道星期日或其他某一天是一周的第一天。

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

我们将使用 模板文字 呈现标记。 要为国际观众格式化日期,我们将使用 Intl.DateTimeFormat API,再次使用 locale 我们之前指定。

月份和年份

当我们调用 month, 我们可以设置是否要使用 long 名称(例如二月)或 short 姓名(例如二月)。 让我们使用 long 名称,因为它是日历上方的标题:

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

工作日名称

对于显示在日期网格上方的工作日,我们既需要 long (例如“星期日”)和 short (缩写,即“Sun”)名称。 这样,当日历空间不足时,我们可以使用“短”名称:

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

让我们制作一个小的辅助方法,使调用每个方法更容易一些:

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

下面是我们如何在模板中调用它:

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

日数

最后,日子,包裹在 <ol> 元件:

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

让我们分解一下:

  1. 我们根据“天数”变量创建一个“虚拟”数组,我们将使用它来迭代。
  2. 我们创建一个 day 迭代中当天的变量。
  3. 我们修复了两者之间的差异 Intl.Locale API和 getDay().
  4. 如果 day 等于 today,我们添加一个 data-* 属性。
  5. 最后,我们返回 <li> 元素作为具有合并数据的字符串。
  6. tabindex="0" 使元素可聚焦,当使用键盘导航时,在任何正的 tabindex 值之后(注意:你应该 决不要积极 标签索引值)

“填充”数字 ,在 datetime 属性,我们使用一些辅助方法:

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

周数

同样,“周数”是 52 周日历中一周的位置。 我们也为此使用了一些辅助方法:

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

这不是我写的 getWeek-方法。 这是一个清理版本 这个脚本.

就是这样! 感谢 Intl.Locale, Intl.DateTimeFormatIntl.NumberFormat API,我们现在可以简单地改变 lang- 的属性 <html> 根据当前区域更改日历上下文的元素:

2023 年 XNUMX 月日历网格。
de-DE
2023 年 XNUMX 月日历网格。
fa-IR
2023 年 XNUMX 月日历网格。
zh-Hans-CN-u-nu-hanidec

设计日历

你可能还记得所有的日子都是一个 <ol> 与列表项。 为了将这些设计成可读的日历,我们深入 CSS Grid 的奇妙世界。 事实上,我们可以重新利用相同的网格 CSS-Tricks 上的入门日历模板,但更新了一点点 :is() 关系伪来优化代码。

请注意,我沿途定义了可配置的 CSS 变量(并在它们前面加上前缀 ---kalel- 以避免冲突)。

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;
}
显示有网格线的七列日历网格。
制作日历时考虑到可访问性和国际化

让我们在日期数字周围绘制边框,以帮助在视觉上将它们分开:

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

当一个月的第一天是七列网格工作正常 所选语言环境一周的第一天)。 但这是例外而不是规则。 大多数时候,我们需要将每个月的第一天换到另一个工作日。

显示该月的第一天是星期四。
制作日历时考虑到可访问性和国际化

记住所有额外的 data-* 我们在编写标记时定义的属性? 我们可以连接到那些以更新哪个网格列(--kalel-li-gc) 该月的第一个日期数字位于:

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

在这种情况下,我们从第一个网格列跨越到第四个网格列——这将自动将下一个项目(第 2 天)“推”到第五个网格列,依此类推。

让我们为“当前”日期添加一点风格,让它脱颖而出。 这些只是我的风格。 你完全可以在这里做你想做的事。

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

我喜欢以不同于工作日的方式设计周末日期数字的想法。 我将使用微红色来设计它们。 请注意,我们可以达到 :not() 伪类来选择它们,同时单独保留当前日期:

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

哦,我们不要忘记每周第一个日期编号之前的周编号。 我们用了一个 data-weeknumber 标记中的属性,但数字实际上不会显示,除非我们用 CSS 显示它们,我们可以在 ::before 伪元素:

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

在这一点上我们在技术上完成了! 我们可以渲染一个显示当前月份日期的日历网格,并考虑按区域设置本地化数据,并确保日历使用正确的语义。 我们使用的只是普通的 JavaScript 和 CSS!

但是让我们把这个 更进一步...

渲染一整年

也许您需要显示一整年的日期! 因此,您可能想要显示当前年份的所有月份网格,而不是呈现当前月份。

好吧,我们使用的方法的好处是我们可以调用 render 方法多次,只更改标识每个实例上的月份的整数。 让我们根据当前年份调用它 12 次。

就像打电话一样简单 render-method 12 次,只需将整数更改为 month - i:

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

为呈现的年份创建一个新的父包装器可能是个好主意。 每个日历网格是一个 <kal-el> 元素。 让我们调用新的父包装器 <jor-el>,其中 Jor-El 是 Kal-El 父亲的名字.

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

我们可以使用 <jor-el> 为我们的网格创建一个网格。 太元了!

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

最终演示

奖励:五彩纸屑日历

我读了一本很棒的书叫 制作和打破网格 前几天偶然发现了这张漂亮的“新年海报”:

制作具有可访问性和国际化的日历 PlatoBlockchain 数据智能。垂直搜索。人工智能。
Sumber: 创建和打破网格(第 2 版) 通过蒂莫西萨马拉

我认为我们可以在不更改 HTML 或 JavaScript 中的任何内容的情况下做类似的事情。 我冒昧地包含了月份的全名和数字而不是日期名称,以使其更具可读性。 享受!

时间戳记:

更多来自 CSS技巧