diff --git a/README.md b/README.md
index 4f835ae..ea20eed 100644
--- a/README.md
+++ b/README.md
@@ -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/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..b704ad8 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,6 +212,7 @@ 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()
}
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..ee87ea2 100644
--- a/test/relative-time.js
+++ b/test/relative-time.js
@@ -561,6 +561,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 () {
@@ -1941,7 +1952,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 +2002,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 +2034,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 +2084,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 +2189,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 +2597,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')
+ })
+ })
})