diff --git a/package.json b/package.json index 2343dd6..b9e4020 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "scripts": { "clean": "rm -rf dist", - "lint": "eslint . --ext .js,.ts && tsc --noEmit", + "lint": "tsc --noEmit && eslint . --ext .js,.ts", "lint:fix": "npm run lint -- --fix", "prebuild": "npm run clean && npm run lint && mkdir dist", "bundle": "esbuild --bundle dist/index.js --keep-names --outfile=dist/bundle.js --format=esm", diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index b704ad8..353655b 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -217,6 +217,18 @@ 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', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + timeZone: this.timeZone, + }).format(date) + } + #updateRenderRootContent(content: string | null): void { if (this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true') { const span = document.createElement('span') @@ -228,6 +240,16 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } } + #shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean { + // Never override duration format with absolute format. + if (format === 'duration') return false + + return ( + this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') === 'true' || + this.ownerDocument.body?.getAttribute('data-prefers-absolute-time') === 'true' + ) + } + #onRelativeTimeUpdated: ((event: RelativeTimeUpdatedEvent) => void) | null = null get onRelativeTimeUpdated() { return this.#onRelativeTimeUpdated @@ -477,12 +499,19 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const duration = elapsedTime(date, this.precision, now) const format = this.#resolveFormat(duration) let newText = oldText - if (format === 'duration') { - newText = this.#getDurationFormat(duration) - } else if (format === 'relative') { - newText = this.#getRelativeFormat(duration) + + // Experimental: Enable absolute time if users prefers it, but never for `duration` format + const displayUserPreferredAbsoluteTime = this.#shouldDisplayUserPreferredAbsoluteTime(format) + if (displayUserPreferredAbsoluteTime) { + newText = this.#getUserPreferredAbsoluteTimeFormat(date) } else { - newText = this.#getDateTimeFormat(date) + if (format === 'duration') { + newText = this.#getDurationFormat(duration) + } else if (format === 'relative') { + newText = this.#getRelativeFormat(duration) + } else { + newText = this.#getDateTimeFormat(date) + } } if (newText) { @@ -496,7 +525,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle)) } - if (format === 'relative' || format === 'duration') { + if ((format === 'relative' || format === 'duration') && !displayUserPreferredAbsoluteTime) { dateObserver.observe(this) } else { dateObserver.unobserve(this) diff --git a/test/relative-time.js b/test/relative-time.js index ee87ea2..850fd9d 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -31,6 +31,7 @@ suite('relative-time', function () { }) teardown(() => { + document.body.removeAttribute('data-prefers-absolute-time') fixture.innerHTML = '' if (dateNow) { // eslint-disable-next-line no-global-assign @@ -1884,6 +1885,53 @@ suite('relative-time', function () { } }) + suite('experimental: [data-prefers-absolute-time]', async () => { + 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') + el.setAttribute('datetime', '2022-01-01T12:00:00.000Z') + await Promise.resolve() + + assert.match(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/) + }) + + test('does not format with absolute time when format is elapsed or duration', async () => { + document.documentElement.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('datetime', '2022-01-01T12:00:00.000Z') + el.setAttribute('format', 'elapsed') + await Promise.resolve() + + assert.notMatch(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/) + }) + + test('does not format with absolute time when data-prefers-absolute-time="false"', async () => { + document.documentElement.setAttribute('data-prefers-absolute-time', 'false') + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 24 * 1000).toISOString()) + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, '3 days ago') + }) + + test('does not format with absolute time when data-prefers-absolute-time attribute is not set', async () => { + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 24 * 1000).toISOString()) + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, '3 days ago') + }) + + test('supports data-prefers-absolute-time="true" on body element too', async () => { + document.body.setAttribute('data-prefers-absolute-time', 'true') + const el = document.createElement('relative-time') + el.setAttribute('datetime', '2022-01-01T12:00:00.000Z') + await Promise.resolve() + + assert.match(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/) + }) + }) + suite('[aria-hidden]', async () => { test('[aria-hidden="true"] applies to shadow root', async () => { const now = new Date().toISOString()