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')