diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 353655b..e3612ef 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -217,15 +217,57 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return `${this.prefix} ${formatter.format(date)}`.trim() } - #getUserPreferredAbsoluteTimeFormat(date: Date): string { - return new Intl.DateTimeFormat(this.#lang, { - day: 'numeric', - month: 'short', + #isToday(date: Date): boolean { + const now = new Date() + const formatter = new Intl.DateTimeFormat(this.#lang, { + timeZone: this.timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + return formatter.format(now) === formatter.format(date) + } + + #isCurrentYear(date: Date): boolean { + const now = new Date() + const formatter = new Intl.DateTimeFormat(this.#lang, { + timeZone: this.timeZone, year: 'numeric', + }) + return formatter.format(now) === formatter.format(date) + } + + // If current day, shows "Today" + time. + // If current year, shows date without year. + // In all other scenarios, show full date. + #getUserPreferredAbsoluteTimeFormat(date: Date): string { + const timeOnlyOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit', timeZoneName: 'short', timeZone: this.timeZone, + } + + if (this.#isToday(date)) { + const relativeFormatter = new Intl.RelativeTimeFormat(this.#lang, {numeric: 'auto'}) + let todayText = relativeFormatter.format(0, 'day') + todayText = todayText.charAt(0).toLocaleUpperCase(this.#lang) + todayText.slice(1) + const timeOnly = new Intl.DateTimeFormat(this.#lang, timeOnlyOptions).format(date) + + return `${todayText} ${timeOnly}` + } + + const timeAndDateOptions: Intl.DateTimeFormatOptions = { + ...timeOnlyOptions, + day: 'numeric', + month: 'short', + } + if (this.#isCurrentYear(date)) { + return new Intl.DateTimeFormat(this.#lang, timeAndDateOptions).format(date) + } + return new Intl.DateTimeFormat(this.#lang, { + ...timeAndDateOptions, + year: 'numeric', }).format(date) } @@ -525,7 +567,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle)) } - if ((format === 'relative' || format === 'duration') && !displayUserPreferredAbsoluteTime) { + const shouldObserve = + format === 'relative' || + format === 'duration' || + (displayUserPreferredAbsoluteTime && (this.#isToday(date) || this.#isCurrentYear(date))) + if (shouldObserve) { dateObserver.observe(this) } else { dateObserver.unobserve(this) diff --git a/test/relative-time.js b/test/relative-time.js index 850fd9d..b9c809d 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -1886,6 +1886,10 @@ suite('relative-time', function () { }) suite('experimental: [data-prefers-absolute-time]', async () => { + teardown(() => { + document.documentElement.removeAttribute('data-prefers-absolute-time') + document.body.removeAttribute('data-prefers-absolute-time') + }) test('formats with absolute time when data-prefers-absolute-time="true"', async () => { document.documentElement.setAttribute('data-prefers-absolute-time', 'true') const el = document.createElement('relative-time') @@ -1930,6 +1934,171 @@ suite('relative-time', function () { assert.match(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/) }) + + test('formats today dates with "Today" text', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'America/New_York') + + el.setAttribute('datetime', '2023-01-15T17:00:00.000Z') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Today 12:00 PM EST') + }) + + test('formats current year dates without year', async () => { + freezeTime(new Date('2023-06-15T12:00:00.000Z')) + + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'America/New_York') + el.setAttribute('datetime', '2023-03-10T18:00:00.000Z') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Mar 10, 1:00 PM EST') + }) + + test('formats different year dates as full date', async () => { + freezeTime(new Date('2023-06-15T12:00:00.000Z')) + + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'America/New_York') + el.setAttribute('datetime', '2022-03-10T18:00:00.000Z') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Mar 10, 2022, 1:00 PM EST') + }) + + test('respects locale formatting', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('lang', 'es-ES') + el.setAttribute('time-zone', 'Europe/Madrid') + + el.setAttribute('datetime', '2023-01-15T17:00:00.000Z') + await Promise.resolve() + + // Spanish formatting - "hoy" = "today", 24-hour format + assert.equal(el.shadowRoot.textContent, 'Hoy 18:00 CET') + }) + + test('uses element time-zone attribute', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'Europe/Paris') + el.setAttribute('datetime', '2023-01-15T17:00:00.000Z') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Today 6:00 PM GMT+1') + }) + + suite('format exclusions', function () { + test('does not activate for format="duration"', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('datetime', '2023-01-15T16:00:00.000Z') + el.setAttribute('format', 'duration') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, '1 hour') + }) + + test('does not activate for format="elapsed"', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('datetime', '2023-01-15T16:00:00.000Z') + el.setAttribute('format', 'elapsed') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, '1h') + }) + + test('does not activate for format="micro"', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('datetime', '2023-01-15T16:00:00.000Z') + el.setAttribute('format', 'micro') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, '1h') + }) + + test('activates for format="relative" (default)', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'GMT') + el.setAttribute('datetime', '2023-01-15T17:00:00.000Z') + el.setAttribute('format', 'relative') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Today 5:00 PM UTC') + }) + + test('activates for format="auto"', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'UTC') + el.setAttribute('datetime', '2023-01-15T17:00:00.000Z') + el.setAttribute('format', 'auto') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Today 5:00 PM UTC') + }) + + test('activates for format="datetime" if current day', async () => { + freezeTime(new Date('2023-01-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'America/New_York') + el.setAttribute('datetime', '2023-01-15T17:00:00.000Z') + el.setAttribute('format', 'datetime') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Today 12:00 PM EST') + }) + + test('activates for format="datetime" if current year but not today', async () => { + freezeTime(new Date('2023-06-15T17:00:00.000Z')) + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + + const el = document.createElement('relative-time') + el.setAttribute('lang', 'en-US') + el.setAttribute('time-zone', 'America/New_York') + el.setAttribute('datetime', '2023-03-10T18:00:00.000Z') // 18:00 UTC = 1:00 PM EST + el.setAttribute('format', 'datetime') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Mar 10, 1:00 PM EST') + }) + }) }) suite('[aria-hidden]', async () => {