diff --git a/README.md b/README.md index 61d0ead..8fe8f87 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ The `micro` format displays relative dates in a more compact format. Similar to If the `threshold` attribute is explicitly set, `micro` will display compact relative dates within the threshold and absolute dates outside of it. If `threshold` is not set, `micro` will continue to display compact relative dates without applying the default threshold. +When `tense` is set to `past` or `future`, `micro` uses `Intl.RelativeTimeFormat` with `style: 'narrow'` to include localized tense phrasing such as `2w ago` or `in 3d`. With the default `tense=auto`, `micro` preserves the compact duration output such as `2w`. + Code that uses `format=micro` should consider migrating to `format=relative` (perhaps with `formatStyle=narrow`), as `format=micro` can be difficult for users to understand, and can cause issues with assistive technologies. For example some screen readers (such as VoiceOver for mac) will read out `1m` as `1 meter`. ###### Cheatsheet @@ -182,15 +184,15 @@ Code that uses `format=micro` should consider migrating to `format=relative` (pe If `format` is `'datetime'` then this value will be ignored. -Tense can be used to prevent `duration` or `relative` formatted dates displaying dates in a tense other than the one specified. Setting `tense=past` will always display future `relative` dates as `now` and `duration` dates as `0 seconds`, while setting it to `future` will always display past dates `relative` as `now` and past `duration` dates as `0 seconds`. +Tense can be used to prevent `duration` or `relative` formatted dates displaying dates in a tense other than the one specified. Setting `tense=past` will always display future `relative` dates as `now` and `duration` dates as `0 seconds`, while setting it to `future` will always display past dates `relative` as `now` and past `duration` dates as `0 seconds`. For `format=micro`, `tense=past` and `tense=future` add localized compact tense phrasing. For example when the given `datetime` is 40 seconds behind of the current date: -| `tense=` | format=duration | format=relative | -| :------: | :-------------: | :-------------: | -| future | 0s | now | -| past | 40s | 40s ago | -| auto | 40s | 40s ago | +| `tense=` | format=duration | format=relative | format=micro | +| :------: | :-------------: | :-------------: | :----------: | +| future | 0s | now | in 1m | +| past | 40s | 40s ago | 1m ago | +| auto | 40s | 40s ago | 1m | ```html diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 088bc36..dec6e8b 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -236,6 +236,28 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return 'datetime' } + #getMicroDuration(duration: Duration): Duration { + duration = roundToSingleUnit(duration) + // Allow month-level durations to pass through even with mismatched tense + if ( + duration.months === 0 && + ((this.tense === 'past' && duration.sign !== -1) || (this.tense === 'future' && duration.sign !== 1)) + ) { + return microEmptyDuration + } + return duration + } + + #getMicroRelativeFormat(duration: Duration): string { + const relativeFormat = new Intl.RelativeTimeFormat(this.#lang, { + numeric: 'always', + style: 'narrow', + }) + duration = this.#getMicroDuration(duration) + const [int, unit] = getRelativeTimeUnit(duration.blank ? microEmptyDuration : duration) + return relativeFormat.format(Math.abs(int) * (this.tense === 'past' ? -1 : 1), unit) + } + #getDurationFormat(duration: Duration): string { const locale = this.#lang const format = this.format @@ -243,15 +265,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const tense = this.tense let empty = emptyDuration if (format === 'micro') { - duration = roundToSingleUnit(duration) + duration = this.#getMicroDuration(duration) empty = microEmptyDuration - // Allow month-level durations to pass through even with mismatched tense - if ( - duration.months === 0 && - ((this.tense === 'past' && duration.sign !== -1) || (this.tense === 'future' && duration.sign !== 1)) - ) { - duration = microEmptyDuration - } } else if ((tense === 'past' && duration.sign !== -1) || (tense === 'future' && duration.sign !== 1)) { duration = empty } @@ -624,7 +639,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor newText = this.#getUserPreferredAbsoluteTimeFormat(date) } else { if (format === 'duration') { - newText = this.#getDurationFormat(duration) + if (this.format === 'micro' && this.tense !== 'auto' && Intl.RelativeTimeFormat) { + newText = this.#getMicroRelativeFormat(duration) + } else { + newText = this.#getDurationFormat(duration) + } } else if (format === 'relative') { newText = this.#getRelativeFormat(duration) } else { diff --git a/test/relative-time.js b/test/relative-time.js index 667f6d8..d4ad5f2 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -384,6 +384,18 @@ suite('relative-time', function () { assert.equal(time.shadowRoot.textContent, 'on Nov 15, 2022') }) + test('micro threshold datetime output does not include tense phrasing', async () => { + freezeTime(new Date('2023-01-01T00:00:00Z')) + const time = document.createElement('relative-time') + time.setAttribute('format', 'micro') + time.setAttribute('lang', 'en-US') + time.setAttribute('tense', 'past') + time.setAttribute('threshold', 'P30D') + time.setAttribute('datetime', '2022-11-15T00:00:00Z') + await Promise.resolve() + assert.equal(time.shadowRoot.textContent, 'on Nov 15, 2022') + }) + test('micro uses duration within explicit P30D threshold', async () => { freezeTime(new Date('2023-01-15T00:00:00Z')) const time = document.createElement('relative-time') @@ -405,6 +417,17 @@ suite('relative-time', function () { assert.equal(time.shadowRoot.textContent, '2mo') }) + test('micro with auto tense remains compact', async () => { + freezeTime(new Date('2023-01-15T00:00:00Z')) + const time = document.createElement('relative-time') + time.setAttribute('format', 'micro') + time.setAttribute('lang', 'en-US') + time.setAttribute('tense', 'auto') + time.setAttribute('datetime', '2023-01-01T00:00:00Z') + await Promise.resolve() + assert.equal(time.shadowRoot.textContent, '2w') + }) + test('uses `prefix` attribute to customise prefix', async () => { freezeTime(new Date('2023-01-01T00:00:00Z')) const time = document.createElement('relative-time') @@ -504,6 +527,17 @@ suite('relative-time', function () { await Promise.resolve() assert.equal(time.shadowRoot.textContent, 'hace 3 días') }) + + test('micro tense phrasing respects lang attribute', async () => { + const now = new Date(Date.now() + 3 * 60 * 60 * 24 * 1000).toISOString() + const time = document.createElement('relative-time') + time.setAttribute('datetime', now) + time.setAttribute('format', 'micro') + time.setAttribute('lang', 'es') + time.setAttribute('tense', 'future') + await Promise.resolve() + assert.equal(time.shadowRoot.textContent, 'dentro de 3 d') + }) } test('renders correctly when given an invalid lang', async () => { @@ -601,7 +635,7 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '2y') + assert.equal(time.shadowRoot.textContent, '2y ago') }) test('micro formats future times', async () => { @@ -611,7 +645,7 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '1m') + assert.equal(time.shadowRoot.textContent, '1m ago') }) test('micro formats hours', async () => { @@ -621,7 +655,7 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '1h') + assert.equal(time.shadowRoot.textContent, '1h ago') }) test('micro formats days', async () => { @@ -631,7 +665,7 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '1d') + assert.equal(time.shadowRoot.textContent, '1d ago') }) test('micro formats months', async () => { @@ -642,7 +676,7 @@ suite('relative-time', function () { time.setAttribute('datetime', datetime) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '2mo') + assert.equal(time.shadowRoot.textContent, '2mo ago') }) }) @@ -703,17 +737,17 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '2y') + assert.equal(time.shadowRoot.textContent, 'in 2y') }) - test('micro formats past times', async () => { + test('micro formats near-future times', async () => { const now = new Date(Date.now() + 3 * 1000).toISOString() const time = document.createElement('relative-time') time.setAttribute('tense', 'future') time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '1m') + assert.equal(time.shadowRoot.textContent, 'in 1m') }) test('micro formats hours', async () => { @@ -723,7 +757,7 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '1h') + assert.equal(time.shadowRoot.textContent, 'in 1h') }) test('micro formats days', async () => { @@ -733,7 +767,7 @@ suite('relative-time', function () { time.setAttribute('datetime', now) time.setAttribute('format', 'micro') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '1d') + assert.equal(time.shadowRoot.textContent, 'in 1d') }) }) @@ -2141,6 +2175,21 @@ suite('relative-time', function () { } }) + test('format="micro" absolute-time preference output does not include tense phrasing', 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-15T16:00:00.000Z') + el.setAttribute('format', 'micro') + el.setAttribute('tense', 'past') + await Promise.resolve() + + assert.equal(el.shadowRoot.textContent, 'Today 4:00 PM UTC') + }) + test('activates for format="relative" (default)', async () => { freezeTime(new Date('2023-01-15T17:00:00.000Z')) document.documentElement.setAttribute('data-prefers-absolute-time', 'true') @@ -2263,13 +2312,13 @@ suite('relative-time', function () { datetime: '2022-10-24T14:46:00.000z', tense: 'future', format: 'micro', - expected: '1m', + expected: 'in 1m', }, { datetime: '2022-10-24T14:46:00.000z', tense: 'past', format: 'micro', - expected: '1m', + expected: '1m ago', }, { datetime: '2022-10-24T14:46:00.000z', @@ -2289,19 +2338,19 @@ suite('relative-time', function () { datetime: '2022-09-24T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '1mo', + expected: 'in 1mo', }, { datetime: '2022-10-23T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '1m', + expected: 'in 1m', }, { datetime: '2022-10-24T13:46:00.000Z', tense: 'future', format: 'micro', - expected: '1m', + expected: 'in 1m', }, // Dates in the future @@ -2309,61 +2358,61 @@ suite('relative-time', function () { datetime: '2022-10-24T15:46:00.000Z', tense: 'future', format: 'micro', - expected: '1h', + expected: 'in 1h', }, { datetime: '2022-10-24T16:00:00.000Z', tense: 'future', format: 'micro', - expected: '1h', + expected: 'in 1h', }, { datetime: '2022-10-24T16:15:00.000Z', tense: 'future', format: 'micro', - expected: '1h', + expected: 'in 1h', }, { datetime: '2022-10-24T16:31:00.000Z', tense: 'future', format: 'micro', - expected: '1h', + expected: 'in 1h', }, { datetime: '2022-10-30T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '1w', + expected: 'in 1w', }, { datetime: '2022-11-24T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '1mo', + expected: 'in 1mo', }, { datetime: '2023-10-23T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '1y', + expected: 'in 1y', }, { datetime: '2023-10-24T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '1y', + expected: 'in 1y', }, { datetime: '2024-03-31T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '2y', + expected: 'in 2y', }, { datetime: '2024-04-01T14:46:00.000Z', tense: 'future', format: 'micro', - expected: '2y', + expected: 'in 2y', }, // Dates in the future @@ -2371,19 +2420,19 @@ suite('relative-time', function () { datetime: '2022-11-24T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1mo', + expected: '1mo ago', }, { datetime: '2022-10-25T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1m', + expected: '1m ago', }, { datetime: '2022-10-24T15:46:00.000Z', tense: 'past', format: 'micro', - expected: '1m', + expected: '1m ago', }, // Dates in the past @@ -2391,61 +2440,61 @@ suite('relative-time', function () { datetime: '2022-10-24T13:46:00.000Z', tense: 'past', format: 'micro', - expected: '1h', + expected: '1h ago', }, { datetime: '2022-10-24T13:30:00.000Z', tense: 'past', format: 'micro', - expected: '1h', + expected: '1h ago', }, { datetime: '2022-10-24T13:17:00.000Z', tense: 'past', format: 'micro', - expected: '1h', + expected: '1h ago', }, { datetime: '2022-10-24T13:01:00.000Z', tense: 'past', format: 'micro', - expected: '1h', + expected: '1h ago', }, { datetime: '2022-10-18T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1w', + expected: '1w ago', }, { datetime: '2022-09-23T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1mo', + expected: '1mo ago', }, { datetime: '2021-10-25T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1y', + expected: '1y ago', }, { datetime: '2021-10-24T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1y', + expected: '1y ago', }, { datetime: '2021-05-18T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1y', + expected: '1y ago', }, { datetime: '2021-05-17T14:46:00.000Z', tense: 'past', format: 'micro', - expected: '1y', + expected: '1y ago', }, // Elapsed Times @@ -2857,14 +2906,14 @@ suite('relative-time', function () { datetime: '2021-12-31T12:00:00.000Z', tense: 'past', format: 'micro', - expected: '1d', + expected: '1d ago', }, { reference: '2022-12-31T12:00:00.000Z', datetime: '2022-01-01T12:00:00.000Z', tense: 'past', format: 'micro', - expected: '1y', + expected: '1y ago', }, { reference: '2022-12-31T12:00:00.000Z', @@ -2878,14 +2927,14 @@ suite('relative-time', function () { datetime: '2024-03-01T12:00:00.000Z', tense: 'future', format: 'micro', - expected: '2y', + expected: 'in 2y', }, { reference: '2021-04-24T12:00:00.000Z', datetime: '2023-02-01T12:00:00.000Z', tense: 'future', format: 'micro', - expected: '2y', + expected: 'in 2y', }, { reference: '2024-01-04T12:00:00.000Z',