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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"scripts": {
"clean": "rm -rf dist",
"lint": "eslint . --ext .js,.ts && tsc --noEmit",
"lint": "tsc --noEmit && eslint . --ext .js,.ts",
"lint:fix": "npm run lint -- --fix",
"prebuild": "npm run clean && npm run lint && mkdir dist",
"bundle": "esbuild --bundle dist/index.js --keep-names --outfile=dist/bundle.js --format=esm",
Expand Down
41 changes: 35 additions & 6 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
return `${this.prefix} ${formatter.format(date)}`.trim()
}

#getUserPreferredAbsoluteTimeFormat(date: Date): string {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now, matching the title format.

Copy link
Contributor

Choose a reason for hiding this comment

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

For other potential reviewers, here's an example of what this looks like: Oct 23, 2025 6:59 AM EDT

return new Intl.DateTimeFormat(this.#lang, {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone: this.timeZone,
}).format(date)
}

#updateRenderRootContent(content: string | null): void {
if (this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true') {
const span = document.createElement('span')
Expand All @@ -228,6 +240,16 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
}
}

#shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean {
// Never override duration format with absolute format.
if (format === 'duration') return false
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think... this makes sense? @zaataylor check me on this :D

Copy link
Contributor

Choose a reason for hiding this comment

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

This makes sense! 🫡 This will also work for elapsed format since they're aliases

// elapsed is an alias for 'duration'
if (format === 'elapsed') return 'duration'

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh and for micro, too:

// 'micro' is an alias for 'duration'
if (format === 'micro') return 'duration'


return (
this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') === 'true' ||
this.ownerDocument.body?.getAttribute('data-prefers-absolute-time') === 'true'
Comment on lines +248 to +249
Copy link
Contributor Author

Choose a reason for hiding this comment

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

adding support for body and html, based on @dgreif comment

staff only: slack

)
}

#onRelativeTimeUpdated: ((event: RelativeTimeUpdatedEvent) => void) | null = null
get onRelativeTimeUpdated() {
return this.#onRelativeTimeUpdated
Expand Down Expand Up @@ -477,12 +499,19 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
const duration = elapsedTime(date, this.precision, now)
const format = this.#resolveFormat(duration)
let newText = oldText
if (format === 'duration') {
newText = this.#getDurationFormat(duration)
} else if (format === 'relative') {
newText = this.#getRelativeFormat(duration)

// Experimental: Enable absolute time if users prefers it, but never for `duration` format
const displayUserPreferredAbsoluteTime = this.#shouldDisplayUserPreferredAbsoluteTime(format)
if (displayUserPreferredAbsoluteTime) {
newText = this.#getUserPreferredAbsoluteTimeFormat(date)
} else {
newText = this.#getDateTimeFormat(date)
if (format === 'duration') {
newText = this.#getDurationFormat(duration)
} else if (format === 'relative') {
newText = this.#getRelativeFormat(duration)
} else {
newText = this.#getDateTimeFormat(date)
}
}

if (newText) {
Expand All @@ -496,7 +525,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle))
}

if (format === 'relative' || format === 'duration') {
if ((format === 'relative' || format === 'duration') && !displayUserPreferredAbsoluteTime) {
dateObserver.observe(this)
} else {
dateObserver.unobserve(this)
Expand Down
48 changes: 48 additions & 0 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ suite('relative-time', function () {
})

teardown(() => {
document.body.removeAttribute('data-prefers-absolute-time')
fixture.innerHTML = ''
if (dateNow) {
// eslint-disable-next-line no-global-assign
Expand Down Expand Up @@ -1884,6 +1885,53 @@ suite('relative-time', function () {
}
})

suite('experimental: [data-prefers-absolute-time]', async () => {
test('formats with absolute time when data-prefers-absolute-time="true"', async () => {
document.documentElement.setAttribute('data-prefers-absolute-time', 'true')
const el = document.createElement('relative-time')
el.setAttribute('datetime', '2022-01-01T12:00:00.000Z')
await Promise.resolve()

assert.match(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/)
})

test('does not format with absolute time when format is elapsed or duration', async () => {
document.documentElement.setAttribute('data-prefers-absolute-time', 'true')
const el = document.createElement('relative-time')
el.setAttribute('datetime', '2022-01-01T12:00:00.000Z')
el.setAttribute('format', 'elapsed')
await Promise.resolve()

assert.notMatch(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/)
})

test('does not format with absolute time when data-prefers-absolute-time="false"', async () => {
document.documentElement.setAttribute('data-prefers-absolute-time', 'false')
const el = document.createElement('relative-time')
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 24 * 1000).toISOString())
await Promise.resolve()

assert.equal(el.shadowRoot.textContent, '3 days ago')
})

test('does not format with absolute time when data-prefers-absolute-time attribute is not set', async () => {
const el = document.createElement('relative-time')
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 24 * 1000).toISOString())
await Promise.resolve()

assert.equal(el.shadowRoot.textContent, '3 days ago')
})

test('supports data-prefers-absolute-time="true" on body element too', async () => {
document.body.setAttribute('data-prefers-absolute-time', 'true')
const el = document.createElement('relative-time')
el.setAttribute('datetime', '2022-01-01T12:00:00.000Z')
await Promise.resolve()

assert.match(el.shadowRoot.textContent, /[A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2} (AM|PM)/)
})
})

suite('[aria-hidden]', async () => {
test('[aria-hidden="true"] applies to shadow root', async () => {
const now = new Date().toISOString()
Expand Down