在设计中考虑无障碍性和国际化的日历制作

Avatar of Mads Stoumann
Mads Stoumann

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

在 CSS-Tricks 上快速搜索一下,就会发现有许多不同的方法来制作日历。 一些方法展示了 CSS Grid 如何高效地创建布局。 有些方法尝试 将实际数据融入其中。 有些方法 依赖于框架 来帮助管理状态。

构建日历组件时需要考虑很多因素,远远超出了我链接的文章中涵盖的内容。 如果您仔细想想,日历充满了细微差别,从处理时区和日期格式到本地化,甚至确保日期从一个月顺畅地流向下一个月…… 这还是在我们甚至没有考虑无障碍性和根据日历显示位置而变化的其他布局因素之前。

许多开发人员害怕 Date() 对象 并坚持使用像 moment.js 这样的旧库。 但是,虽然日期和格式化有很多“陷阱”,但 JavaScript 拥有许多强大的 API 和工具来提供帮助!

January 2023 calendar grid.

我不想在这里重新造轮子,但我会向您展示如何使用原生 JavaScript 获取一个非常棒的日历。 我们将深入探讨 **无障碍性**,使用语义标记和对屏幕阅读器友好的 <time> 标签,以及 **国际化** 和 **格式化**,使用 Intl.LocaleIntl.DateTimeFormatIntl.NumberFormat API。

换句话说,我们正在制作一个日历…… 只是没有在像这样的教程中通常会用到的额外依赖项,并且包含了一些您可能不会通常看到的细微差别。 而且,在此过程中,我希望您能对 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。 这是朝着 国际化日历 的第一步。

我们还需要确定渲染日历时最初要显示哪个月份。 这就是为什么我们将主 date 添加到 config 对象中。 通过这种方式,如果 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。 这有点令人困惑,因为 JavaScript 中的 getDay 方法 将这些日子返回为 [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 年 1 月”

<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 也可以在这里提供帮助。 我们将在下一节涵盖渲染时介绍它。

日期数字

日历网格中的每个日期都会获得一个数字。 每个数字都是有序列表 (<ol>) 中的一个列表项 (<li>),并且内联 <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`(缩写,即“星期日”)名称。这样,当日历空间不足时,我们就可以使用“short”名称。

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 值之后(注意:你 **绝不** 应该添加 **正** 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.DateTimeFormat``Intl.NumberFormat` API,我们现在只需要更改 `<html>` 元素的 `lang` 属性,即可根据当前区域更改日历的上下文。

January 2023 calendar grid.
de-DE
January 2023 calendar grid.
fa-IR
January 2023 calendar grid.
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;
}
Seven-column calendar grid with grid lines shown.

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

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

当月份的第一天也是所选区域的星期中的第一天时,七列网格效果很好)。但这是例外,而不是常态。大多数情况下,我们需要将月份的第一天移到不同的星期。

Showing the first day of the month falling on a Thursday.

还记得我们在编写标记时定义的所有额外的 `data-*` 属性吗?我们可以利用这些属性来更新月份第一个日期数字放置到的网格列(`--kalel-li-gc`)。

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

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

让我们为“当前”日期添加一些样式,以便它脱颖而出。这些只是我的样式。你可以在此处完全按照自己的意愿进行操作。

[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` 方法 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);
}

最终演示

奖励:彩带日历

前几天我读了一本很棒的书,叫做 Making and Breaking the Grid,并且偶然发现了这张漂亮的“新年海报”。

来源:Making and Breaking the Grid (第二版),作者 Timothy Samara

我认为我们可以做类似的事情,而无需更改 HTML 或 JavaScript 中的任何内容。我已经添加了月份的全称和数字,而不是星期名称,以便使它更易读。尽情享用吧!