Skip to content
Merged
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ This is identical to `format=relative`. Code that uses `format=auto` should migr

###### `format=micro`

The `micro` format which will display relative dates (within the threshold) in a more compact format. Similar to `relative`, the `micro` format rounds values to the nearest largest value. Additionally, `micro` format will not round _lower_ than 1 minute, as such a `datetime` which is less than a minute from the current wall clock time will display `'1m'`.
The `micro` format displays relative dates in a more compact format. Similar to `relative`, the `micro` format rounds values to the nearest largest value. Additionally, `micro` format will not round _lower_ than 1 minute, as such a `datetime` which is less than a minute from the current wall clock time will display `'1m'`.

If the `threshold` attribute is explicitly set, `micro` will display compact relative dates within the threshold and absolute dates outside of it. If `threshold` is not set, `micro` will continue to display compact relative dates without applying the default threshold.

Code that uses `format=micro` should consider migrating to `format=relative` (perhaps with `formatStyle=narrow`), as `format=micro` can be difficult for users to understand, and can cause issues with assistive technologies. For example some screen readers (such as VoiceOver for mac) will read out `1m` as `1 meter`.

Expand Down Expand Up @@ -230,11 +232,11 @@ Precision can be used to limit the display of an `relative` or `duration` format

##### threshold (`string`, default: `P30D`)

If `tense` is anything other than `'auto'`, or `format` is `'relative'` (or the deprecated `'auto'` or `'micro'` values), then this value will be ignored.
If `tense` is anything other than `'auto'`, or `format` is not `'relative'` (or the deprecated `'auto'` value), then this value will be ignored. For `format="micro"`, `threshold` is honored only when the `threshold` attribute is explicitly set.

Comment on lines 233 to 236
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 26ca974 by correcting the threshold docs: threshold applies to relative/auto when tense is auto, and micro only honors it when explicitly set.

Threshold can be used to specify when a relative display (e.g. "5 days ago") should turn into an absolute display (i.e. the full date). This should be a valid [ISO8601 Time Duration](https://en.wikipedia.org/wiki/ISO_8601#Durations). If the difference between the current time and the specified `datetime` is _more_ than the duration, then the date will be displayed as an absolute value (i.e. the full date), otherwise it will be formatted to a relative display (e.g. "5 days ago").

The default value for this is `P30D`, meaning if the current time is more than 30 days away from the specified date time, then an absolute date will be displayed.
The default value for this is `P30D`, meaning if the current time is more than 30 days away from the specified date time, then an absolute date will be displayed. This default applies to relative/auto formatting; `format="micro"` only applies threshold behavior when the `threshold` attribute is present.

```html
<relative-time datetime="1970-04-01T16:30:00-08:00" threshold="P100Y">
Expand Down
92 changes: 73 additions & 19 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import {Duration, elapsedTime, getRelativeTimeUnit, isDuration, roundToSingleUnit, Unit, unitNames} from './duration.js'
import {
Duration,
applyDuration,
elapsedTime,
getRelativeTimeUnit,
isDuration,
roundToSingleUnit,
Unit,
unitNames,
} from './duration.js'
const HTMLElement = globalThis.HTMLElement || (null as unknown as typeof window['HTMLElement'])

export type DeprecatedFormat = 'auto' | 'micro' | 'elapsed'
Expand All @@ -17,7 +26,9 @@ export class RelativeTimeUpdatedEvent extends Event {
}

function getUnitFactor(el: RelativeTimeElement): number {
if (!el.date) return Infinity
const date = el.date
if (!date) return Infinity
const now = Date.now()
if (el.format === 'duration' || el.format === 'elapsed') {
const precision = el.precision
if (precision === 'second') {
Expand All @@ -26,10 +37,40 @@ function getUnitFactor(el: RelativeTimeElement): number {
return 60 * 1000
}
}
const ms = Math.abs(Date.now() - el.date.getTime())
if (ms < 60 * 1000) return 1000
if (ms < 60 * 60 * 1000) return 60 * 1000
return 60 * 60 * 1000
const ms = Math.abs(now - date.getTime())
let factor = 60 * 60 * 1000
if (ms < 60 * 1000) {
factor = 1000
} else if (ms < 60 * 60 * 1000) {
factor = 60 * 1000
}
const threshold = getExplicitThreshold(el)
if (el.format === 'micro' && threshold) {
const thresholdDuration = Duration.from(threshold)
const signedThresholdDuration = date.getTime() > now ? negateDuration(thresholdDuration) : thresholdDuration
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the important live-update bit for threshold fallback: the normal observer cadence can be too late, so explicit micro thresholds schedule at date - threshold for future dates and date + threshold for past dates.

const thresholdTime = applyDuration(date, signedThresholdDuration).getTime()
const msUntilThreshold = thresholdTime - now
if (msUntilThreshold > 0) factor = Math.min(factor, msUntilThreshold)
}
return factor
}

function negateDuration(duration: Duration): Duration {
return new Duration(
-duration.years,
-duration.months,
-duration.weeks,
-duration.days,
-duration.hours,
-duration.minutes,
-duration.seconds,
-duration.milliseconds,
)
}

function getExplicitThreshold(el: RelativeTimeElement): string | null {
const threshold = el.getAttribute('threshold')
return threshold && isDuration(threshold) ? threshold : null
}

// Determine whether the user has a 12 (vs. 24) hour cycle preference via the
Expand All @@ -45,15 +86,16 @@ function isBrowser12hCycle(): boolean {
const dateObserver = new (class {
elements: Set<RelativeTimeElement> = new Set()
time = Infinity
updating = false

observe(element: RelativeTimeElement) {
if (this.elements.has(element)) return
this.elements.add(element)
if (this.updating) return
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This intentionally allows already-observed elements to be rescheduled when threshold is added or changed. The updating guard keeps that from creating duplicate timers while the observer loop is running.

const date = element.date
if (date && date.getTime()) {
const ms = getUnitFactor(element)
const time = Date.now() + ms
if (time < this.time) {
if (time < this.time || this.time <= Date.now()) {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.update(), ms)
this.time = time
Expand All @@ -72,9 +114,14 @@ const dateObserver = new (class {
if (!this.elements.size) return

let nearestDistance = Infinity
for (const timeEl of this.elements) {
nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl))
timeEl.update()
this.updating = true
try {
for (const timeEl of this.elements) {
nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl))
timeEl.update()
}
} finally {
this.updating = false
}
this.time = Math.min(60 * 60 * 1000, nearestDistance)
this.timer = setTimeout(() => this.update(), this.time)
Expand Down Expand Up @@ -163,15 +210,22 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
}).format(date)
}

#resolveFormat(duration: Duration): ResolvedFormat {
#getExplicitThreshold(): string | null {
return getExplicitThreshold(this)
}

#resolveFormat(duration: Duration, thresholdDuration = duration): ResolvedFormat {
const format: string = this.format
if (format === 'datetime') return 'datetime'
if (format === 'duration') return 'duration'

// elapsed is an alias for 'duration'
if (format === 'elapsed') return 'duration'
// 'micro' is an alias for 'duration'
if (format === 'micro') return 'duration'
if (format === 'micro') {
const threshold = this.#getExplicitThreshold()
if (threshold && Duration.compare(thresholdDuration, threshold) === -1) return 'datetime'
return 'duration'
}

// 'auto' is an alias for 'relative'
if ((format === 'auto' || format === 'relative') && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {
Expand Down Expand Up @@ -305,8 +359,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
}

#shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean {
// Never override duration format with absolute format.
if (format === 'duration') return false
// Never override duration or elapsed format with absolute format.
if (format === 'duration' && this.format !== 'micro') return false

Comment on lines 359 to 364
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 26ca974 by making observation conditional on rendered output: user-preferred absolute micro output no longer keeps duration-style polling except for Today/current-year absolute-time updates.

return (
this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') === 'true' ||
Expand Down Expand Up @@ -561,7 +615,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
}

const duration = elapsedTime(date, this.precision, now)
const format = this.#resolveFormat(duration)
const format = this.#resolveFormat(duration, elapsedTime(date, 'millisecond', now))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Using a millisecond-precision duration here keeps threshold resolution independent of display precision. Micro still renders with its normal minute floor, but threshold cutoffs are based on the actual elapsed time.

let newText = oldText

// Experimental: Enable absolute time if users prefers it, but never for `duration` format
Expand Down Expand Up @@ -590,8 +644,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
}

const shouldObserve =
format === 'relative' ||
format === 'duration' ||
(!displayUserPreferredAbsoluteTime && (format === 'relative' || format === 'duration')) ||
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This keeps user-preferred absolute output from polling like duration output. Micro only remains observed for explicit threshold transitions or the existing Today/current-year absolute-time cases.

(this.format === 'micro' && Boolean(this.#getExplicitThreshold()) && date.getTime() > now) ||
(displayUserPreferredAbsoluteTime && (this.#isToday(date) || this.#isCurrentYear(date)))
if (shouldObserve) {
dateObserver.observe(this)
Expand Down
100 changes: 98 additions & 2 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ suite('relative-time', function () {
}
})

test('reschedules future micro datetime updates at the explicit threshold boundary', async () => {
const originalSetTimeout = window.setTimeout
const delays = []
const time = document.createElement('relative-time')
globalThis.setTimeout = window.setTimeout = function (_, ms) {
delays.push(ms)
return 1
}
try {
time.setAttribute('format', 'micro')
time.setAttribute('datetime', new Date(Date.now() + 65 * 1000).toISOString())
await Promise.resolve()
assert.equal(time.shadowRoot.textContent, '1m')
assert.isAtLeast(delays[0], 59000)

delays.length = 0
time.setAttribute('threshold', 'PT1M')
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This test covers the dynamic attribute case: a micro element can already be observed, then gain an explicit threshold that requires an earlier reschedule.

await Promise.resolve()
assert.match(time.shadowRoot.textContent, /on [A-Z][a-z]{2} \d{1,2}/)
assert.isAbove(delays[0], 0)
assert.isBelow(delays[0], 6000)

delays.length = 0
const pastTime = document.createElement('relative-time')
pastTime.setAttribute('format', 'micro')
pastTime.setAttribute('threshold', 'PT1H')
pastTime.setAttribute('datetime', new Date(Date.now() - 59 * 60 * 1000 - 58 * 1000).toISOString())
await Promise.resolve()
assert.equal(pastTime.shadowRoot.textContent, '1h')
assert.isAbove(delays[0], 0)
assert.isBelow(delays[0], 6000)
pastTime.disconnectedCallback()
} finally {
globalThis.setTimeout = window.setTimeout = originalSetTimeout
time.disconnectedCallback()
}
})

test('does not call update() frequently with attributeChangedCallback', async () => {
let counter = 0
const el = document.createElement('relative-time')
Expand Down Expand Up @@ -335,6 +373,38 @@ suite('relative-time', function () {
assert.match(time.shadowRoot.textContent, /on [A-Z][a-z]{2} \d{1,2}/)
})

test('micro switches to dates after explicit P30D threshold', async () => {
freezeTime(new Date('2023-01-01T00:00:00Z'))
const time = document.createElement('relative-time')
time.setAttribute('format', 'micro')
time.setAttribute('lang', 'en-US')
time.setAttribute('threshold', 'P30D')
time.setAttribute('datetime', '2022-11-15T00:00:00Z')
await Promise.resolve()
assert.equal(time.shadowRoot.textContent, 'on Nov 15, 2022')
})

test('micro uses duration within explicit P30D threshold', async () => {
freezeTime(new Date('2023-01-15T00:00:00Z'))
const time = document.createElement('relative-time')
time.setAttribute('format', 'micro')
time.setAttribute('lang', 'en-US')
time.setAttribute('threshold', 'P30D')
time.setAttribute('datetime', '2023-01-01T00:00:00Z')
await Promise.resolve()
assert.equal(time.shadowRoot.textContent, '2w')
})

test('micro ignores default P30D threshold unless threshold attribute is set', async () => {
freezeTime(new Date('2023-01-01T00:00:00Z'))
const time = document.createElement('relative-time')
time.setAttribute('format', 'micro')
time.setAttribute('lang', 'en-US')
time.setAttribute('datetime', '2022-11-15T00:00:00Z')
await Promise.resolve()
assert.equal(time.shadowRoot.textContent, '2mo')
})

test('uses `prefix` attribute to customise prefix', async () => {
freezeTime(new Date('2023-01-01T00:00:00Z'))
const time = document.createElement('relative-time')
Expand Down Expand Up @@ -2032,17 +2102,43 @@ suite('relative-time', function () {
assert.equal(el.shadowRoot.textContent, '1h')
})

test('does not activate for format="micro"', async () => {
test('activates for format="micro"', async () => {
freezeTime(new Date('2023-01-15T17:00:00.000Z'))
document.documentElement.setAttribute('data-prefers-absolute-time', 'true')

const el = document.createElement('relative-time')
el.setAttribute('lang', 'en-US')
el.setAttribute('time-zone', 'GMT')
el.setAttribute('datetime', '2023-01-15T16:00:00.000Z')
el.setAttribute('format', 'micro')
await Promise.resolve()

assert.equal(el.shadowRoot.textContent, '1h')
assert.equal(el.shadowRoot.textContent, 'Today 4:00 PM UTC')
})

test('does not observe old format="micro" absolute-time preference output', async () => {
freezeTime(new Date('2023-01-15T17:00:00.000Z'))
document.documentElement.setAttribute('data-prefers-absolute-time', 'true')
const originalSetTimeout = window.setTimeout
const delays = []
globalThis.setTimeout = window.setTimeout = function (_, ms) {
delays.push(ms)
return 1
}
try {
const el = document.createElement('relative-time')
el.setAttribute('lang', 'en-US')
el.setAttribute('time-zone', 'GMT')
el.setAttribute('datetime', '2022-01-15T17:00:00.000Z')
el.setAttribute('format', 'micro')
await Promise.resolve()

assert.equal(el.shadowRoot.textContent, 'Jan 15, 2022, 5:00 PM UTC')
assert.empty(delays)
el.disconnectedCallback()
} finally {
globalThis.setTimeout = window.setTimeout = originalSetTimeout
}
})

test('activates for format="relative" (default)', async () => {
Expand Down
Loading