Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'` | <sup>\*</sup> |
| `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` | <sup>\*\*</sup> |
| `day` | `day` | `'numeric'\|'2-digit'\|undefined` | `'numeric'` |
| `month` | `month` | `'numeric'\|'2-digit'\|'short'\|'long'\|'narrow'\|undefined` | <sup>\*\*\*</sup> |
| `year` | `year` | `'numeric'\|'2-digit'\|undefined` | <sup>\*\*\*\*</sup> |
| `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'` | <sup>\*</sup> |
| `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` | <sup>\*\*</sup> |
| `day` | `day` | `'numeric'\|'2-digit'\|undefined` | `'numeric'` |
| `month` | `month` | `'numeric'\|'2-digit'\|'short'\|'long'\|'narrow'\|undefined` | <sup>\*\*\*</sup> |
| `year` | `year` | `'numeric'\|'2-digit'\|undefined` | <sup>\*\*\*\*</sup> |
| `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` | `-` | `-` |

<sup>\*</sup>: 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'`.

Expand Down
14 changes: 14 additions & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ <h3>Format DateTime</h3>
</relative-time>
</p>

<p>
h12 cycle:
<relative-time datetime="1970-01-01T00:00:00.000Z" format="datetime" hour="numeric" minute="2-digit" second="2-digit" hour-cycle="h12">
Jan 1 1970
</relative-time>
</p>

<p>
h23 cycle:
<relative-time datetime="1970-01-01T00:00:00.000Z" format="datetime" hour="numeric" minute="2-digit" second="2-digit" hour-cycle="h23">
Jan 1 1970
</relative-time>
</p>

<p>
Customised options:
<relative-time datetime="1970-01-01T00:00:00.000Z" format="datetime" weekday="narrow" year="2-digit" month="narrow" day="numeric" hour="numeric" minute="2-digit" second="2-digit">
Expand Down
23 changes: 23 additions & 0 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I no longer work at GitHub, nor do I maintain this repository any more, but I was curious about this change.

I think this sould probably use the defined locale (this.#lang) so that if a page allows customisation of the rendered locale, this will take precedent over the browser.

Copy link
Contributor Author

@silverwind silverwind Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think hour cycle is a personal user preference and the document language (which #lang derives from) should not overrule that preference. I prefer 24h and don't want to see 12h just because I happen to browse a en-US document.

The current implementation here uses DateTimeFormat([]) which makes the browser use its "default locale". How a browser determines it is not specced, but here is what Chrome on MacOS does as of today (written by Claude):

  1. [] → empty locale list — V8's CanonicalizeLocaleList returns an empty vector.
  2. Fallback to ICU default locale — V8 calls icu::Locale::getDefault().getName() and converts it to a BCP 47 tag.
  3. ICU default was set at renderer startup — In content/renderer/renderer_main.cc, Chromium calls
    base::i18n::SetICUDefaultLocale() with the value from Chrome's GetApplicationLocale(). On macOS, this reads
    NSBundle.preferredLocalizations[0] — the first OS language Chrome has a .pak translation for.
  4. Hour cycle from CLDR data — V8 creates an ICU DateTimePatternGenerator for the resolved locale and calls
    udatpg_getDefaultHourCycle(), which looks up the locale's region in CLDR's timeData (e.g., US → h12, GB/DE → h23).
  5. No OS override — Chrome ships its own bundled ICU (not Apple's system ICU), so macOS settings like
    AppleICUForce24HourTime or the "24-hour time" toggle have zero effect. The hour cycle comes entirely from CLDR data for
    the resolved locale's region.

In my case 24h is determined from my locale's region (EU), and the OS setting has no effect because Google decided to not use Apple's ICU.

} catch {
return false
}
}

const dateObserver = new (class {
elements: Set<RelativeTimeElement> = new Set()
time = Infinity
Expand Down Expand Up @@ -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() {
Expand All @@ -122,6 +141,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
'title',
'aria-hidden',
'time-zone',
'hour-cycle',
]
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)) {
Expand Down
130 changes: 130 additions & 0 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand Down