diff --git a/README.md b/README.md
index 5c399f7..84d293f 100644
--- a/README.md
+++ b/README.md
@@ -56,26 +56,27 @@ So, a relative date phrase is used for up to a month and then the actual date is
#### Attributes
-| Property Name | Attribute Name | Possible Values | Default Value |
-| :------------- | :--------------- | :------------------------------------------------------------------------------------------ | :------------------------ |
-| `datetime` | `datetime` | `string` | - |
-| `format` | `format` | `'datetime'\|'relative'\|'duration'` | `'auto'` |
-| `date` | - | `Date \| null` | - |
-| `tense` | `tense` | `'auto'\|'past'\|'future'` | `'auto'` |
-| `precision` | `precision` | `'year'\|'month'\|'day'\|'hour'\|'minute'\|'second'` | `'second'` |
-| `threshold` | `threshold` | `string` | `'P30D'` |
-| `prefix` | `prefix` | `string` | `'on'` |
-| `formatStyle` | `format-style` | `'long'\|'short'\|'narrow'` | \* |
-| `second` | `second` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
-| `minute` | `minute` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
-| `hour` | `hour` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
-| `weekday` | `weekday` | `'short'\|'long'\|'narrow'\|undefined` | \*\* |
-| `day` | `day` | `'numeric'\|'2-digit'\|undefined` | `'numeric'` |
-| `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` | `-` | `-` |
+| Property Name | Attribute Name | Possible Values | Default Value |
+| :------------- | :--------------- | :------------------------------------------------------------------------------------------ | :------------------------------- |
+| `datetime` | `datetime` | `string` | - |
+| `format` | `format` | `'datetime'\|'relative'\|'duration'` | `'auto'` |
+| `date` | - | `Date \| null` | - |
+| `tense` | `tense` | `'auto'\|'past'\|'future'` | `'auto'` |
+| `precision` | `precision` | `'year'\|'month'\|'day'\|'hour'\|'minute'\|'second'` | `'second'` |
+| `threshold` | `threshold` | `string` | `'P30D'` |
+| `prefix` | `prefix` | `string` | `'on'` |
+| `formatStyle` | `format-style` | `'long'\|'short'\|'narrow'` | \* |
+| `second` | `second` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
+| `minute` | `minute` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
+| `hour` | `hour` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
+| `weekday` | `weekday` | `'short'\|'long'\|'narrow'\|undefined` | \*\* |
+| `day` | `day` | `'numeric'\|'2-digit'\|undefined` | `'numeric'` |
+| `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 |
+| `hourCycle` | `hour-cycle` | `'h11'\|'h12'\|'h23'\|'h24'\|undefined` | `'h12'` or `'h23'` based on browser |
+| `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'`.
diff --git a/examples/index.html b/examples/index.html
index 886af5c..b190940 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -29,6 +29,20 @@
Format DateTime
+
+ h12 cycle:
+
+ Jan 1 1970
+
+
+
+
+ h23 cycle:
+
+ Jan 1 1970
+
+
+
Customised options:
diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts
index e3612ef..9b278ce 100644
--- a/src/relative-time-element.ts
+++ b/src/relative-time-element.ts
@@ -32,6 +32,16 @@ function getUnitFactor(el: RelativeTimeElement): number {
return 60 * 60 * 1000
}
+// Determine whether the user has a 12 (vs. 24) hour cycle preference via the
+// browser's resolved DateTimeFormat options.
+function isBrowser12hCycle(): boolean {
+ try {
+ return new Intl.DateTimeFormat([], {hour: 'numeric'}).resolvedOptions().hour12 === true
+ } catch {
+ return false
+ }
+}
+
const dateObserver = new (class {
elements: Set = new Set()
time = Infinity
@@ -98,6 +108,15 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
return tz || undefined
}
+ get hourCycle() {
+ // Prefer attribute, then closest, then document
+ const hc =
+ this.closest('[hour-cycle]')?.getAttribute('hour-cycle') ||
+ this.ownerDocument.documentElement.getAttribute('hour-cycle')
+ if (hc === 'h11' || hc === 'h12' || hc === 'h23' || hc === 'h24') return hc
+ return isBrowser12hCycle() ? 'h12' : 'h23'
+ }
+
#renderRoot: Node = this.shadowRoot ? this.shadowRoot : this.attachShadow ? this.attachShadow({mode: 'open'}) : this
static get observedAttributes() {
@@ -122,6 +141,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
'title',
'aria-hidden',
'time-zone',
+ 'hour-cycle',
]
}
@@ -139,6 +159,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
minute: '2-digit',
timeZoneName: 'short',
timeZone: this.timeZone,
+ hourCycle: this.hourCycle,
}).format(date)
}
@@ -213,6 +234,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
year: this.year,
timeZoneName: this.timeZoneName,
timeZone: this.timeZone,
+ hourCycle: this.hourCycle,
})
return `${this.prefix} ${formatter.format(date)}`.trim()
}
@@ -246,6 +268,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
minute: '2-digit',
timeZoneName: 'short',
timeZone: this.timeZone,
+ hourCycle: this.hourCycle,
}
if (this.#isToday(date)) {
diff --git a/test/relative-time.js b/test/relative-time.js
index b9c809d..b2a863d 100644
--- a/test/relative-time.js
+++ b/test/relative-time.js
@@ -1982,6 +1982,7 @@ suite('relative-time', function () {
const el = document.createElement('relative-time')
el.setAttribute('lang', 'es-ES')
el.setAttribute('time-zone', 'Europe/Madrid')
+ el.setAttribute('hour-cycle', 'h23')
el.setAttribute('datetime', '2023-01-15T17:00:00.000Z')
await Promise.resolve()
@@ -2815,6 +2816,135 @@ suite('relative-time', function () {
}
})
+ suite('[hourCycle]', function () {
+ test('formats with 24-hour cycle when hour-cycle is h23', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('hour-cycle', 'h23')
+ await Promise.resolve()
+ assert.notMatch(el.shadowRoot.textContent, /AM|PM/i)
+ assert.match(el.shadowRoot.textContent, /15:00/)
+ })
+
+ test('formats with 12-hour cycle when hour-cycle is h12', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('hour-cycle', 'h12')
+ await Promise.resolve()
+ assert.match(el.shadowRoot.textContent, /3:00/)
+ assert.match(el.shadowRoot.textContent, /PM/i)
+ })
+
+ test('formats with 12-hour cycle when hour-cycle is h11', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('hour-cycle', 'h11')
+ await Promise.resolve()
+ assert.match(el.shadowRoot.textContent, /3:00/)
+ assert.match(el.shadowRoot.textContent, /PM/i)
+ })
+
+ test('formats with 24-hour cycle when hour-cycle is h24', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('hour-cycle', 'h24')
+ await Promise.resolve()
+ assert.notMatch(el.shadowRoot.textContent, /AM|PM/i)
+ assert.match(el.shadowRoot.textContent, /15:00/)
+ })
+
+ test('title uses hour-cycle setting', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('hour-cycle', 'h23')
+ await Promise.resolve()
+ assert.notMatch(el.getAttribute('title'), /AM|PM/i)
+ assert.match(el.getAttribute('title'), /15/)
+ })
+
+ test('inherits hour-cycle from ancestor', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ const div = document.createElement('div')
+ div.setAttribute('hour-cycle', 'h23')
+ div.appendChild(el)
+ document.body.appendChild(div)
+ await Promise.resolve()
+ assert.notMatch(el.shadowRoot.textContent, /AM|PM/i)
+ assert.match(el.shadowRoot.textContent, /15:00/)
+ div.remove()
+ })
+
+ test('inherits hour-cycle from documentElement', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ document.documentElement.setAttribute('hour-cycle', 'h23')
+ await Promise.resolve()
+ assert.notMatch(el.shadowRoot.textContent, /AM|PM/i)
+ assert.match(el.shadowRoot.textContent, /15:00/)
+ document.documentElement.removeAttribute('hour-cycle')
+ })
+
+ test('element attribute overrides ancestor', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('hour-cycle', 'h12')
+ const div = document.createElement('div')
+ div.setAttribute('hour-cycle', 'h23')
+ div.appendChild(el)
+ document.body.appendChild(div)
+ await Promise.resolve()
+ assert.match(el.shadowRoot.textContent, /3:00/)
+ assert.match(el.shadowRoot.textContent, /PM/i)
+ div.remove()
+ })
+
+ test('re-renders when hour-cycle attribute changes', async () => {
+ const el = document.createElement('relative-time')
+ el.setAttribute('datetime', '2020-01-01T15:00:00.000Z')
+ el.setAttribute('time-zone', 'UTC')
+ el.setAttribute('format', 'datetime')
+ el.setAttribute('hour', 'numeric')
+ el.setAttribute('minute', '2-digit')
+ el.setAttribute('hour-cycle', 'h12')
+ await Promise.resolve()
+ assert.match(el.shadowRoot.textContent, /PM/i)
+ el.setAttribute('hour-cycle', 'h23')
+ await Promise.resolve()
+ assert.notMatch(el.shadowRoot.textContent, /AM|PM/i)
+ assert.match(el.shadowRoot.textContent, /15:00/)
+ })
+ })
+
suite('[timeZone]', function () {
test('updates when the time-zone attribute is set', async () => {
const el = document.createElement('relative-time')