diff --git a/README.md b/README.md
index 4f835ae..59ff70c 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ Depending on how far in the future this is being viewed, the element's text will
- 20 days from now
- 4 hours from now
- 7 minutes from now
-- just now
+- now
- 30 seconds ago
- a minute ago
- 30 minutes ago
@@ -80,6 +80,7 @@ So, a relative date phrase is used for up to a month and then the actual date is
| `month` | `month` | `'numeric'\|'2-digit'\|'short'\|'long'\|'narrow'\|undefined` | *** |
| `year` | `year` | `'numeric'\|'2-digit'\|undefined` | **** |
| `timeZoneName` | `time-zone-name` | `'long'\|'short'\|'shortOffset'\|'longOffset'` `\|'shortGeneric'\|'longGeneric'\|undefined` | `undefined` |
+| `timeZone` | `time-zone` | `string\|undefined` | Browser default time zone |
| `noTitle` | `no-title` | `-` | `-` |
*: If unspecified, `formatStyle` will return `'narrow'` if `format` is `'elapsed'` or `'micro'`, `'short'` if the format is `'relative'` or `'datetime'`, otherwise it will be `'long'`.
@@ -139,6 +140,19 @@ The `duration` format will display the time remaining (or elapsed time) from the
- `4 hours`
- `8 days, 30 minutes, 1 second`
+##### time-zone (`string`)
+
+The`time-zone` attribute allows you to specify the IANA time zone name (e.g., `America/New_York`, `Europe/London`) used for formatting the date and time.
+
+You can set the time zone either as an attribute or property:
+```html
+
+ June 1, 2024 8:00am EDT
+
+```
+
+If the individual element does not have a `time-zone` attribute then it will traverse upwards in the tree to find the closest element that does, or default the `time-zone` to the browsers default.
+
###### Deprecated Formats
###### `format=elapsed`
@@ -259,7 +273,7 @@ This will used to determine the length of the unit names. This value is passed t
| relative | narrow | in 1 mo. |
| duration | long | 1 month, 2 days, 4 hours |
| duration | short | 1 mth, 2 days, 4 hr |
-| duration | narrow | 1m 2d 4h |
+| duration | narrow | 1mo 2d 4h |
##### second, minute, hour, weekday, day, month, year, timeZoneName
diff --git a/package-lock.json b/package-lock.json
index b9755e3..92ebd0c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7651,10 +7651,11 @@
}
},
"node_modules/tar-fs": {
- "version": "3.0.8",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
- "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
+ "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
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/duration-format-ponyfill.ts b/src/duration-format-ponyfill.ts
index 15ccd7d..122e061 100644
--- a/src/duration-format-ponyfill.ts
+++ b/src/duration-format-ponyfill.ts
@@ -110,7 +110,15 @@ export default class DurationFormat {
: unitStyle === 'numeric'
? {}
: {style: 'unit', unit: nfUnit, unitDisplay: unitStyle}
- list.push(new Intl.NumberFormat(locale, nfOpts).format(value))
+
+ let formattedValue = new Intl.NumberFormat(locale, nfOpts).format(value)
+
+ // Custom handling for narrow month formatting to use "mo" instead of "m"
+ if (unit === 'months' && (unitStyle === 'narrow' || (style === 'narrow' && formattedValue.endsWith('m')))) {
+ formattedValue = formattedValue.replace(/(\d+)m$/, '$1mo')
+ }
+
+ list.push(formattedValue)
}
return new ListFormat(locale, {
type: 'unit',
diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts
index 80dc977..353655b 100644
--- a/src/relative-time-element.ts
+++ b/src/relative-time-element.ts
@@ -90,6 +90,14 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
}
}
+ get timeZone() {
+ // Prefer attribute, then closest, then document
+ const tz =
+ this.closest('[time-zone]')?.getAttribute('time-zone') ||
+ this.ownerDocument.documentElement.getAttribute('time-zone')
+ return tz || undefined
+ }
+
#renderRoot: Node = this.shadowRoot ? this.shadowRoot : this.attachShadow ? this.attachShadow({mode: 'open'}) : this
static get observedAttributes() {
@@ -113,6 +121,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
'lang',
'title',
'aria-hidden',
+ 'time-zone',
]
}
@@ -129,6 +138,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
+ timeZone: this.timeZone,
}).format(date)
}
@@ -160,7 +170,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
if (format === 'micro') {
duration = roundToSingleUnit(duration)
empty = microEmptyDuration
- if ((this.tense === 'past' && duration.sign !== -1) || (this.tense === 'future' && duration.sign !== 1)) {
+ // 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)) {
@@ -198,10 +212,23 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
month: this.month,
year: this.year,
timeZoneName: this.timeZoneName,
+ timeZone: this.timeZone,
})
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')
@@ -213,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
@@ -462,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) {
@@ -481,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/duration-format-ponyfill.ts b/test/duration-format-ponyfill.ts
index 5aaf496..94f018c 100644
--- a/test/duration-format-ponyfill.ts
+++ b/test/duration-format-ponyfill.ts
@@ -37,7 +37,7 @@ suite('duration format ponyfill', function () {
locale: 'en',
style: 'narrow',
parts: [
- {type: 'element', value: '1m'},
+ {type: 'element', value: '1mo'},
{type: 'literal', value: ' '},
{type: 'element', value: '2d'},
{type: 'literal', value: ' '},
@@ -92,6 +92,20 @@ suite('duration format ponyfill', function () {
{type: 'element', value: '8s'},
],
},
+ {
+ duration: 'P1M2DT3M30S',
+ locale: 'en',
+ style: 'narrow',
+ parts: [
+ {type: 'element', value: '1mo'},
+ {type: 'literal', value: ' '},
+ {type: 'element', value: '2d'},
+ {type: 'literal', value: ' '},
+ {type: 'element', value: '3m'},
+ {type: 'literal', value: ' '},
+ {type: 'element', value: '30s'},
+ ],
+ },
])
for (const {duration, locale, parts, ...opts} of tests) {
diff --git a/test/relative-time.js b/test/relative-time.js
index de5c102..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
@@ -561,6 +562,17 @@ suite('relative-time', function () {
await Promise.resolve()
assert.equal(time.shadowRoot.textContent, '1d')
})
+
+ test('micro formats months', async () => {
+ const datetime = new Date()
+ datetime.setMonth(datetime.getMonth() - 2)
+ const time = document.createElement('relative-time')
+ time.setAttribute('tense', 'past')
+ time.setAttribute('datetime', datetime)
+ time.setAttribute('format', 'micro')
+ await Promise.resolve()
+ assert.equal(time.shadowRoot.textContent, '2mo')
+ })
})
suite('[tense=future]', function () {
@@ -1873,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()
@@ -1941,7 +2000,7 @@ suite('relative-time', function () {
datetime: '2022-09-24T14:46:00.000Z',
tense: 'future',
format: 'micro',
- expected: '1m',
+ expected: '1mo',
},
{
datetime: '2022-10-23T14:46:00.000Z',
@@ -1991,7 +2050,7 @@ suite('relative-time', function () {
datetime: '2022-11-24T14:46:00.000Z',
tense: 'future',
format: 'micro',
- expected: '1m',
+ expected: '1mo',
},
{
datetime: '2023-10-23T14:46:00.000Z',
@@ -2023,7 +2082,7 @@ suite('relative-time', function () {
datetime: '2022-11-24T14:46:00.000Z',
tense: 'past',
format: 'micro',
- expected: '1m',
+ expected: '1mo',
},
{
datetime: '2022-10-25T14:46:00.000Z',
@@ -2073,7 +2132,7 @@ suite('relative-time', function () {
datetime: '2022-09-23T14:46:00.000Z',
tense: 'past',
format: 'micro',
- expected: '1m',
+ expected: '1mo',
},
{
datetime: '2021-10-25T14:46:00.000Z',
@@ -2178,13 +2237,13 @@ suite('relative-time', function () {
{
datetime: '2021-10-30T14:46:00.000Z',
format: 'elapsed',
- expected: '11m 29d',
+ expected: '11mo 29d',
},
{
datetime: '2021-10-30T14:46:00.000Z',
format: 'elapsed',
precision: 'month',
- expected: '11m',
+ expected: '11mo',
},
{
datetime: '2021-10-29T14:46:00.000Z',
@@ -2586,4 +2645,62 @@ suite('relative-time', function () {
})
}
})
+
+ suite('[timeZone]', function () {
+ test('updates when the time-zone attribute is set', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T12:00:00.000Z')
+ el.setAttribute('time-zone', 'America/New_York')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('second', '2-digit')
+ el.setAttribute('time-zone-name', 'longGeneric')
+ await Promise.resolve()
+ assert.equal(el.shadowRoot.textContent, 'Wed, Jan 1, 2020, 7:00:00 AM Eastern Time')
+ })
+
+ test('updates when the time-zone attribute changes', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T12:00:00.000Z')
+ el.setAttribute('time-zone', 'America/New_York')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('second', '2-digit')
+ await Promise.resolve()
+ const initial = el.shadowRoot.textContent
+ el.setAttribute('time-zone', 'Asia/Tokyo')
+ await Promise.resolve()
+ assert.notEqual(el.shadowRoot.textContent, initial)
+ assert.equal(el.shadowRoot.textContent, 'Wed, Jan 1, 2020, 9:00:00 PM')
+ })
+
+ test('ignores empty time-zone attributes', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T12:00:00.000Z')
+ el.setAttribute('time-zone', '')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('second', '2-digit')
+ await Promise.resolve()
+ // Should fallback to default or system time zone
+ assert.equal(el.shadowRoot.textContent, 'Wed, Jan 1, 2020, 4:00:00 PM')
+ })
+
+ test('uses html time-zone if element time-zone is empty', async () => {
+ const time = document.createElement('relative-time')
+ time.setAttribute('datetime', '2020-01-01T12:00:00.000Z')
+ time.setAttribute('time-zone', '')
+ document.documentElement.setAttribute('time-zone', 'Asia/Tokyo')
+ time.setAttribute('format', 'datetime')
+ time.setAttribute('hour', 'numeric')
+ time.setAttribute('minute', '2-digit')
+ time.setAttribute('second', '2-digit')
+ await Promise.resolve()
+ assert.equal(time.shadowRoot.textContent, 'Wed, Jan 1, 2020, 9:00:00 PM')
+ document.documentElement.removeAttribute('time-zone')
+ })
+ })
})