From 28e09ffc67554fc396333af18297da3e95e2f080 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 13 Mar 2026 11:43:17 +0100 Subject: [PATCH] Vendor relative-time-element as local web component (#36853) Replace the `@github/relative-time-element` npm dependency with a vendored, simplified implementation. - Support 24h format rendering [PR 329](https://github.com/github/relative-time-element/pull/329) - Enable `::selection` styling in Firefox [PR 341](https://github.com/github/relative-time-element/pull/341) - Remove timezone from tooltips (It's always local timezone) - Clean up previous `title` workaround in tippy - Remove unused features - Use native `Intl.DurationFormat` with fallback for older browsers, remove dead polyfill - Add MIT license header to vendored file - Add unit tests - Add dedicated devtest page for all component variants --------- Signed-off-by: silverwind Co-authored-by: Claude claude-opus-4-6 20250630 --- package.json | 1 - pnpm-lock.yaml | 8 - routers/web/devtest/devtest.go | 36 +- templates/devtest/gitea-ui.tmpl | 20 - templates/devtest/relative-time.tmpl | 66 +++ web_src/css/base.css | 3 +- web_src/js/modules/tippy.ts | 15 +- web_src/js/utils/time.ts | 1 - web_src/js/webcomponents/index.ts | 2 +- web_src/js/webcomponents/polyfills.ts | 18 - .../js/webcomponents/relative-time.test.ts | 139 +++++ web_src/js/webcomponents/relative-time.ts | 498 ++++++++++++++++++ 12 files changed, 734 insertions(+), 73 deletions(-) create mode 100644 templates/devtest/relative-time.tmpl create mode 100644 web_src/js/webcomponents/relative-time.test.ts create mode 100644 web_src/js/webcomponents/relative-time.ts diff --git a/package.json b/package.json index 284bde5d08..11dc64ee6a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "@citation-js/plugin-software-formats": "0.6.2", "@github/markdown-toolbar-element": "2.2.3", "@github/paste-markdown": "1.5.3", - "@github/relative-time-element": "5.0.0", "@github/text-expander-element": "2.9.4", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mermaid-js/layout-elk": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c42b326fce..553364a5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: '@github/paste-markdown': specifier: 1.5.3 version: 1.5.3 - '@github/relative-time-element': - specifier: 5.0.0 - version: 5.0.0 '@github/text-expander-element': specifier: 2.9.4 version: 2.9.4 @@ -745,9 +742,6 @@ packages: '@github/paste-markdown@1.5.3': resolution: {integrity: sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==} - '@github/relative-time-element@5.0.0': - resolution: {integrity: sha512-L/2r0DNR/rMbmHWcsdmhtOiy2gESoGOhItNFD4zJ3nZfHl79Dx3N18Vfx/pYr2lruMOdk1cJZb4wEumm+Dxm1w==} - '@github/text-expander-element@2.9.4': resolution: {integrity: sha512-+zxSlek2r0NrbFmRfymVtYhES9YU033acc/mouXUkN2bs8DaYScPucvBhwg/5d0hsEb2rIykKnkA/2xxWSqCTw==} @@ -4636,8 +4630,6 @@ snapshots: '@github/paste-markdown@1.5.3': {} - '@github/relative-time-element@5.0.0': {} - '@github/text-expander-element@2.9.4': dependencies: '@github/combobox-nav': 2.3.1 diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index a22d376579..8283d3ad9d 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -51,16 +51,7 @@ func FetchActionTest(ctx *context.Context) { ctx.JSONRedirect("") } -func prepareMockDataGiteaUI(ctx *context.Context) { - now := time.Now() - ctx.Data["TimeNow"] = now - ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) - ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) - ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) - ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) - ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) - ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second) -} +func prepareMockDataGiteaUI(_ *context.Context) {} func prepareMockDataBadgeCommitSign(ctx *context.Context) { var commits []*asymkey.SignCommit @@ -166,6 +157,29 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) { ctx.Data["SelectedStyle"] = selectedStyle } +func prepareMockDataRelativeTime(ctx *context.Context) { + now := time.Now() + ctx.Data["TimeNow"] = now + ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) + ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) + ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) + ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) + ctx.Data["TimePast3m"] = now.Add(-3 * time.Minute) + ctx.Data["TimePast1h"] = now.Add(-1 * time.Hour) + ctx.Data["TimePast3h"] = now.Add(-3 * time.Hour) + ctx.Data["TimePast1d"] = now.Add(-24 * time.Hour) + ctx.Data["TimePast2d"] = now.Add(-2 * 24 * time.Hour) + ctx.Data["TimePast3d"] = now.Add(-3 * 24 * time.Hour) + ctx.Data["TimePast26h"] = now.Add(-26 * time.Hour) + ctx.Data["TimePast40d"] = now.Add(-40 * 24 * time.Hour) + ctx.Data["TimePast60d"] = now.Add(-60 * 24 * time.Hour) + ctx.Data["TimePast1y"] = now.Add(-366 * 24 * time.Hour) + ctx.Data["TimeFuture1h"] = now.Add(1 * time.Hour) + ctx.Data["TimeFuture3h"] = now.Add(3 * time.Hour) + ctx.Data["TimeFuture3d"] = now.Add(3 * 24 * time.Hour) + ctx.Data["TimeFuture1y"] = now.Add(366 * 24 * time.Hour) +} + func prepareMockData(ctx *context.Context) { switch ctx.Req.URL.Path { case "/devtest/gitea-ui": @@ -174,6 +188,8 @@ func prepareMockData(ctx *context.Context) { prepareMockDataBadgeCommitSign(ctx) case "/devtest/badge-actions-svg": prepareMockDataBadgeActionsSvg(ctx) + case "/devtest/relative-time": + prepareMockDataRelativeTime(ctx) } } diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index 6167ce0253..d548ed81dc 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -117,15 +117,6 @@ -
-

Absolute Dates

-
-
-
-
-
-
-

LocaleNumber

{{ctx.Locale.PrettyNumber 1}}
@@ -137,17 +128,6 @@
{{ctx.Locale.PrettyNumber 1234567}}
-
-

TimeSince

-
Now: {{DateUtils.TimeSince .TimeNow}}
-
5s past: {{DateUtils.TimeSince .TimePast5s}}
-
5s future: {{DateUtils.TimeSince .TimeFuture5s}}
-
2m past: {{DateUtils.TimeSince .TimePast2m}}
-
2m future: {{DateUtils.TimeSince .TimeFuture2m}}
-
1y past: {{DateUtils.TimeSince .TimePast1y}}
-
1y future: {{DateUtils.TimeSince .TimeFuture1y}}
-
-

SVG alignment

diff --git a/templates/devtest/relative-time.tmpl b/templates/devtest/relative-time.tmpl new file mode 100644 index 0000000000..ff2485ac01 --- /dev/null +++ b/templates/devtest/relative-time.tmpl @@ -0,0 +1,66 @@ +{{template "devtest/devtest-header"}} +
+
+
+

Relative (auto)

+
now:
+
3m ago:
+
3h ago:
+
1d ago:
+
3d ago:
+
3d future:
+
40d ago (threshold):
+
+
+

tense=past

+
3m ago:
+
future clamped:
+
60d ago:
+
+
+

tense=future

+
3h future:
+
past clamped:
+
+
+

Duration

+
0s:
+
3h:
+
1d 2h:
+
short:
+
narrow:
+
+
+

Datetime (absolute)

+
default:
+
month=short:
+
month=long:
+
numeric:
+
weekday:
+
with time:
+
+
+

Threshold

+
P0Y:
+
P1D:
+
P30D:
+
+
+

Prefix

+
default:
+
prefix="":
+
prefix="at":
+
+
+

TimeSince (Go helper)

+
now: {{DateUtils.TimeSince .TimeNow}}
+
5s past: {{DateUtils.TimeSince .TimePast5s}}
+
5s future: {{DateUtils.TimeSince .TimeFuture5s}}
+
2m past: {{DateUtils.TimeSince .TimePast2m}}
+
2m future: {{DateUtils.TimeSince .TimeFuture2m}}
+
1y past: {{DateUtils.TimeSince .TimePast1y}}
+
1y future: {{DateUtils.TimeSince .TimeFuture1y}}
+
+
+
+{{template "devtest/devtest-footer"}} diff --git a/web_src/css/base.css b/web_src/css/base.css index 59072839a9..1515b12320 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -238,7 +238,8 @@ progress::-moz-progress-bar { background: var(--color-hover); } -::selection { +::selection, +relative-time::part(root)::selection { background: var(--color-primary-light-1); color: var(--color-white); } diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index c57116db36..22eb875c97 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -1,6 +1,5 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; -import {formatDatetime} from '../utils/time.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; import {html} from '../utils/html.ts'; @@ -100,20 +99,10 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc } function switchTitleToTooltip(target: Element): void { - let title = target.getAttribute('title'); + const title = target.getAttribute('title'); if (title) { - // apply custom formatting to relative-time's tooltips - if (target.tagName.toLowerCase() === 'relative-time') { - const datetime = target.getAttribute('datetime'); - if (datetime) { - title = formatDatetime(new Date(datetime)); - } - } target.setAttribute('data-tooltip-content', title); target.setAttribute('aria-label', title); - // keep the attribute, in case there are some other "[title]" selectors - // and to prevent infinite loop with which will re-add - // title if it is absent target.setAttribute('title', ''); } } @@ -155,7 +144,7 @@ export function initGlobalTooltips(): void { const observerConnect = (observer: MutationObserver) => observer.observe(document, { subtree: true, childList: true, - attributeFilter: ['data-tooltip-content', 'title'], + attributeFilter: ['data-tooltip-content'], }); const observer = new MutationObserver((mutationList, observer) => { const pending = observer.takeRecords(); diff --git a/web_src/js/utils/time.ts b/web_src/js/utils/time.ts index 359cb8357b..994c8c8f3b 100644 --- a/web_src/js/utils/time.ts +++ b/web_src/js/utils/time.ts @@ -77,7 +77,6 @@ export function formatDatetime(date: Date | number): string { hour: 'numeric', hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())), minute: '2-digit', - timeZoneName: 'short', }); } return dateFormat.format(date); diff --git a/web_src/js/webcomponents/index.ts b/web_src/js/webcomponents/index.ts index 8251f6ddae..f2d725814d 100644 --- a/web_src/js/webcomponents/index.ts +++ b/web_src/js/webcomponents/index.ts @@ -1,4 +1,4 @@ import './polyfills.ts'; -import '@github/relative-time-element'; +import './relative-time.ts'; import './origin-url.ts'; import './overflow-menu.ts'; diff --git a/web_src/js/webcomponents/polyfills.ts b/web_src/js/webcomponents/polyfills.ts index 3093510ed7..bf04cd430c 100644 --- a/web_src/js/webcomponents/polyfills.ts +++ b/web_src/js/webcomponents/polyfills.ts @@ -1,21 +1,3 @@ -try { - // some browsers like PaleMoon don't have full support for Intl.NumberFormat, so do the minimum polyfill to support "relative-time-element" - // https://repo.palemoon.org/MoonchildProductions/UXP/issues/2289 - new Intl.NumberFormat('en', {style: 'unit', unit: 'minute'}).format(1); -} catch { - const intlNumberFormat = Intl.NumberFormat; - Intl.NumberFormat = function(locales: string | string[], options: Intl.NumberFormatOptions) { - if (options.style === 'unit') { - return { - format(value: number | bigint | string) { - return ` ${value} ${options.unit}`; - }, - } as Intl.NumberFormat; - } - return intlNumberFormat(locales, options); - } as unknown as typeof Intl.NumberFormat; -} - export function weakRefClass() { const weakMap = new WeakMap(); return class { diff --git a/web_src/js/webcomponents/relative-time.test.ts b/web_src/js/webcomponents/relative-time.test.ts new file mode 100644 index 0000000000..009006c71b --- /dev/null +++ b/web_src/js/webcomponents/relative-time.test.ts @@ -0,0 +1,139 @@ +import './relative-time.ts'; + +function createRelativeTime(datetime: string, attrs: Record = {}): HTMLElement { + const el = document.createElement('relative-time'); + el.setAttribute('lang', 'en'); + el.setAttribute('datetime', datetime); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + return el; +} + +function getText(el: HTMLElement): string { + return el.shadowRoot!.textContent ?? ''; +} + +test('renders "now" for current time', async () => { + const el = createRelativeTime(new Date().toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('now'); +}); + +test('renders minutes ago', async () => { + const el = createRelativeTime(new Date(Date.now() - 3 * 60 * 1000).toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('3 minutes ago'); +}); + +test('renders hours ago', async () => { + const el = createRelativeTime(new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('3 hours ago'); +}); + +test('renders yesterday', async () => { + const el = createRelativeTime(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('yesterday'); +}); + +test('renders days ago', async () => { + const el = createRelativeTime(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('3 days ago'); +}); + +test('renders future time', async () => { + const el = createRelativeTime(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('in 3 days'); +}); + +test('switches to datetime format after default threshold', async () => { + const el = createRelativeTime(new Date(Date.now() - 32 * 24 * 60 * 60 * 1000).toISOString(), {lang: 'en-US'}); + await Promise.resolve(); + expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/); +}); + +test('ignores invalid datetime', async () => { + const el = createRelativeTime('bogus'); + el.shadowRoot!.textContent = 'fallback'; + await Promise.resolve(); + expect(getText(el)).toBe('fallback'); +}); + +test('handles empty datetime', async () => { + const el = createRelativeTime(''); + el.shadowRoot!.textContent = 'fallback'; + await Promise.resolve(); + expect(getText(el)).toBe('fallback'); +}); + +test('tense=past shows relative time beyond threshold', async () => { + const el = createRelativeTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), {tense: 'past'}); + await Promise.resolve(); + expect(getText(el)).toMatch(/months? ago/); +}); + +test('tense=past clamps future to now', async () => { + const el = createRelativeTime(new Date(Date.now() + 3000).toISOString(), {tense: 'past'}); + await Promise.resolve(); + expect(getText(el)).toBe('now'); +}); + +test('format=duration renders duration', async () => { + const el = createRelativeTime(new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), {format: 'duration'}); + await Promise.resolve(); + expect(getText(el)).toMatch(/hours?/); +}); + +test('format=datetime renders formatted date', async () => { + const el = createRelativeTime(new Date().toISOString(), {format: 'datetime', lang: 'en-US'}); + await Promise.resolve(); + expect(getText(el)).toMatch(/[A-Z][a-z]{2}, [A-Z][a-z]{2} \d{1,2}/); +}); + +test('sets data-tooltip-content', async () => { + const el = createRelativeTime(new Date().toISOString()); + await Promise.resolve(); + expect(el.getAttribute('data-tooltip-content')).toBeTruthy(); + expect(el.getAttribute('aria-label')).toBe(el.getAttribute('data-tooltip-content')); +}); + +test('respects lang from parent element', async () => { + const container = document.createElement('span'); + container.setAttribute('lang', 'de'); + const el = document.createElement('relative-time'); + el.setAttribute('datetime', new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString()); + container.append(el); + await Promise.resolve(); + expect(getText(el)).toBe('vor 3 Tagen'); +}); + +test('switches to datetime with P1D threshold', async () => { + const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), { + lang: 'en-US', + threshold: 'P1D', + }); + await Promise.resolve(); + expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/); +}); + +test('batches multiple attribute changes into single update', async () => { + const el = document.createElement('relative-time'); + el.setAttribute('lang', 'en'); + el.setAttribute('datetime', new Date().toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('now'); + + let updateCount = 0; + const origUpdate = (el as any).update; + (el as any).update = function () { + updateCount++; + return origUpdate.call(this); + }; + el.setAttribute('second', '2-digit'); + el.setAttribute('hour', '2-digit'); + el.setAttribute('minute', '2-digit'); + await Promise.resolve(); + expect(updateCount).toBe(1); +}); diff --git a/web_src/js/webcomponents/relative-time.ts b/web_src/js/webcomponents/relative-time.ts new file mode 100644 index 0000000000..80d96652d7 --- /dev/null +++ b/web_src/js/webcomponents/relative-time.ts @@ -0,0 +1,498 @@ +// Vendored and simplified from @github/relative-time-element@4.4.6 +// https://github.com/github/relative-time-element +// +// MIT License +// +// Copyright (c) 2014-2018 GitHub, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +type FormatStyle = 'long' | 'short' | 'narrow'; + +const unitNames = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] as const; + +const durationRe = /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; + +function parseDurationMs(str: string): number { + const m = durationRe.exec(str); + if (!m) return -1; + const [, y, mo, w, d, h, min, s] = m.map(Number); + return ((y || 0) * 365.25 + (mo || 0) * 30.44 + (w || 0) * 7 + (d || 0)) * 86400000 + + ((h || 0) * 3600 + (min || 0) * 60 + (s || 0)) * 1000; +} + +type Sign = -1 | 0 | 1; + +class Duration { + readonly years: number; + readonly months: number; + readonly weeks: number; + readonly days: number; + readonly hours: number; + readonly minutes: number; + readonly seconds: number; + readonly sign: Sign; + readonly blank: boolean; + + constructor( + years = 0, months = 0, weeks = 0, days = 0, + hours = 0, minutes = 0, seconds = 0, + ) { + this.years = years || 0; + this.months = months || 0; + this.weeks = weeks || 0; + this.days = days || 0; + this.hours = hours || 0; + this.minutes = minutes || 0; + this.seconds = seconds || 0; + this.sign = (Math.sign(this.years) || Math.sign(this.months) || Math.sign(this.weeks) || + Math.sign(this.days) || Math.sign(this.hours) || Math.sign(this.minutes) || + Math.sign(this.seconds)) as Sign; + this.blank = this.sign === 0; + } + + abs(): Duration { + return new Duration( + Math.abs(this.years), Math.abs(this.months), Math.abs(this.weeks), Math.abs(this.days), + Math.abs(this.hours), Math.abs(this.minutes), Math.abs(this.seconds), + ); + } +} + +function elapsedTime(date: Date, now = Date.now()): Duration { + const delta = date.getTime() - now; + if (delta === 0) return new Duration(); + const sign = Math.sign(delta); + const ms = Math.abs(delta); + const sec = Math.floor(ms / 1000); + const min = Math.floor(sec / 60); + const hr = Math.floor(min / 60); + const day = Math.floor(hr / 24); + const month = Math.floor(day / 30); + const year = Math.floor(month / 12); + return new Duration( + year * sign, + (month - year * 12) * sign, + 0, + (day - month * 30) * sign, + (hr - day * 24) * sign, + (min - hr * 60) * sign, + (sec - min * 60) * sign, + ); +} + +function roundToSingleUnit(duration: Duration, {relativeTo = Date.now()}: {relativeTo?: Date | number} = {}): Duration { + relativeTo = new Date(relativeTo); + if (duration.blank) return duration; + const sign = duration.sign; + let years = Math.abs(duration.years); + let months = Math.abs(duration.months); + let weeks = Math.abs(duration.weeks); + let days = Math.abs(duration.days); + let hours = Math.abs(duration.hours); + let minutes = Math.abs(duration.minutes); + let seconds = Math.abs(duration.seconds); + if (seconds >= 55) minutes += Math.round(seconds / 60); + if (minutes || hours || days || weeks || months || years) seconds = 0; + if (minutes >= 55) hours += Math.round(minutes / 60); + if (hours || days || weeks || months || years) minutes = 0; + if (days && hours >= 12) days += Math.round(hours / 24); + if (!days && hours >= 21) days += Math.round(hours / 24); + if (days || weeks || months || years) hours = 0; + const currentYear = relativeTo.getFullYear(); + const currentMonth = relativeTo.getMonth(); + const currentDate = relativeTo.getDate(); + if (days >= 27 || years + months + days) { + const newMonthDate = new Date(relativeTo); + newMonthDate.setDate(1); + newMonthDate.setMonth(currentMonth + months * sign + 1); + newMonthDate.setDate(0); + const monthDateCorrection = Math.max(0, currentDate - newMonthDate.getDate()); + const newDate = new Date(relativeTo); + newDate.setFullYear(currentYear + years * sign); + newDate.setDate(currentDate - monthDateCorrection); + newDate.setMonth(currentMonth + months * sign); + newDate.setDate(currentDate - monthDateCorrection + days * sign); + const yearDiff = newDate.getFullYear() - relativeTo.getFullYear(); + const monthDiff = newDate.getMonth() - relativeTo.getMonth(); + const daysDiff = Math.abs(Math.round((Number(newDate) - Number(relativeTo)) / 86400000)) + monthDateCorrection; + const monthsDiff = Math.abs(yearDiff * 12 + monthDiff); + if (daysDiff < 27) { + if (days >= 6) { + weeks += Math.round(days / 7); + days = 0; + } else { + days = daysDiff; + } + months = years = 0; + } else if (monthsDiff <= 11) { + months = monthsDiff; + years = 0; + } else { + months = 0; + years = yearDiff * sign; + } + if (months || years) days = 0; + } + if (years) months = 0; + if (weeks >= 4) months += Math.round(weeks / 4); + if (months || years) weeks = 0; + if (days && weeks && !months && !years) { + weeks += Math.round(days / 7); + days = 0; + } + return new Duration(years * sign, months * sign, weeks * sign, days * sign, hours * sign, minutes * sign, seconds * sign); +} + +function getRelativeTimeUnit(duration: Duration, opts?: {relativeTo?: Date | number}): [number, Intl.RelativeTimeFormatUnit] { + const rounded = roundToSingleUnit(duration, opts); + if (rounded.blank) return [0, 'second']; + for (const unit of unitNames) { + const val = (rounded as any)[`${unit}s`]; + if (val) return [val, unit]; + } + return [0, 'second']; +} + +type Format = 'auto' | 'datetime' | 'relative' | 'duration'; +type ResolvedFormat = 'datetime' | 'relative' | 'duration'; +type Tense = 'auto' | 'past' | 'future'; + +const emptyDuration = new Duration(); + +let cachedBrowser12hCycle: boolean | undefined; +function isBrowser12hCycle(): boolean { + return cachedBrowser12hCycle ??= new Intl.DateTimeFormat(undefined, {hour: 'numeric'}) + .resolvedOptions().hourCycle === 'h12'; +} + +function getUnitFactor(el: RelativeTime): number { + if (!el.date) return Infinity; + if (el.format === 'duration') return 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 dateObserver = new (class { + elements = new Set(); + time = Infinity; + timer = -1; + + observe(element: RelativeTime): void { + this.elements.add(element); + const date = element.date; + if (date && !Number.isNaN(date.getTime())) { + const ms = getUnitFactor(element); + const time = Date.now() + ms; + if (time < this.time) { + clearTimeout(this.timer); + this.timer = window.setTimeout(() => this.update(), ms); + this.time = time; + } + } + } + + unobserve(element: RelativeTime): void { + if (!this.elements.has(element)) return; + this.elements.delete(element); + if (!this.elements.size) { + clearTimeout(this.timer); + this.time = Infinity; + } + } + + update(): void { + clearTimeout(this.timer); + if (!this.elements.size) return; + let nearestDistance = Infinity; + for (const timeEl of this.elements) { + nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)); + timeEl.update(); + } + this.time = Math.min(60 * 60 * 1000, nearestDistance); + this.timer = window.setTimeout(() => this.update(), this.time); + this.time += Date.now(); + } +})(); + +class RelativeTime extends HTMLElement { + static observedAttributes = [ + 'second', 'minute', 'hour', 'weekday', 'day', 'month', 'year', + 'prefix', 'threshold', 'tense', 'format', 'format-style', + 'datetime', 'lang', 'hour-cycle', + ]; + + #updating = false; + #renderRoot: ShadowRoot | HTMLElement; + #span = document.createElement('span'); + + constructor() { // eslint-disable-line wc/no-constructor -- shadow DOM setup requires constructor + super(); + this.#renderRoot = this.shadowRoot || this.attachShadow?.({mode: 'open'}) || this; + this.#span.setAttribute('part', 'root'); + this.#renderRoot.replaceChildren(this.#span); + } + + get hourCycle(): string | undefined { + const hc = this.closest('[hour-cycle]')?.getAttribute('hour-cycle'); + if (hc === 'h11' || hc === 'h12' || hc === 'h23' || hc === 'h24') return hc; + return isBrowser12hCycle() ? 'h12' : 'h23'; + } + + get #lang(): string { + const lang = this.closest('[lang]')?.getAttribute('lang'); + if (!lang) return navigator.language; + try { + return new Intl.Locale(lang).toString(); + } catch { + return navigator.language; + } + } + + get second(): 'numeric' | '2-digit' | undefined { + const v = this.getAttribute('second'); + if (v === 'numeric' || v === '2-digit') return v; + return undefined; + } + + get minute(): 'numeric' | '2-digit' | undefined { + const v = this.getAttribute('minute'); + if (v === 'numeric' || v === '2-digit') return v; + return undefined; + } + + get hour(): 'numeric' | '2-digit' | undefined { + const v = this.getAttribute('hour'); + if (v === 'numeric' || v === '2-digit') return v; + return undefined; + } + + get weekday(): 'long' | 'short' | 'narrow' | undefined { + const weekday = this.getAttribute('weekday'); + if (weekday === 'long' || weekday === 'short' || weekday === 'narrow') return weekday; + if (this.format === 'datetime' && weekday !== '') return this.formatStyle; + return undefined; + } + + get day(): 'numeric' | '2-digit' | undefined { + const day = this.getAttribute('day') ?? 'numeric'; + if (day === 'numeric' || day === '2-digit') return day; + return undefined; + } + + get month(): 'numeric' | '2-digit' | 'short' | 'long' | 'narrow' | undefined { + const format = this.format; + let month = this.getAttribute('month'); + if (month === '') return undefined; + month ??= format === 'datetime' ? this.formatStyle : 'short'; + if (month === 'numeric' || month === '2-digit' || month === 'short' || month === 'long' || month === 'narrow') { + return month; + } + return undefined; + } + + get year(): 'numeric' | '2-digit' | undefined { + const year = this.getAttribute('year'); + if (year === 'numeric' || year === '2-digit') return year; + if (!this.hasAttribute('year') && new Date().getFullYear() !== this.date?.getFullYear()) { + return 'numeric'; + } + return undefined; + } + + get prefix(): string { + return this.getAttribute('prefix') ?? (this.format === 'datetime' ? '' : 'on'); + } + + get #thresholdMs(): number { + const ms = parseDurationMs(this.getAttribute('threshold') ?? ''); + return ms >= 0 ? ms : 30 * 86400000; + } + + get tense(): Tense { + const tense = this.getAttribute('tense'); + if (tense === 'past') return 'past'; + if (tense === 'future') return 'future'; + return 'auto'; + } + + get format(): Format { + const format = this.getAttribute('format'); + if (format === 'datetime') return 'datetime'; + if (format === 'relative') return 'relative'; + if (format === 'duration') return 'duration'; + return 'auto'; + } + + get formatStyle(): FormatStyle { + const formatStyle = this.getAttribute('format-style'); + if (formatStyle === 'long') return 'long'; + if (formatStyle === 'short') return 'short'; + if (formatStyle === 'narrow') return 'narrow'; + if (this.format === 'datetime') return 'short'; + return 'long'; + } + + get datetime(): string { + return this.getAttribute('datetime') || ''; + } + + get date(): Date | null { + const parsed = Date.parse(this.datetime); + return Number.isNaN(parsed) ? null : new Date(parsed); + } + + connectedCallback(): void { + this.update(); + } + + disconnectedCallback(): void { + dateObserver.unobserve(this); + } + + attributeChangedCallback(_attrName: string, oldValue: string | null, newValue: string | null): void { + if (oldValue === newValue) return; + if (!this.#updating) { + this.#updating = true; + queueMicrotask(() => { + this.update(); + this.#updating = false; + }); + } + } + + #getFormattedTitle(date: Date): string { + return new Intl.DateTimeFormat(this.#lang, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hourCycle: this.hourCycle as Intl.DateTimeFormatOptions['hourCycle'], + }).format(date); + } + + #resolveFormat(elapsedMs: number): ResolvedFormat { + const format = this.format; + if (format === 'datetime') return 'datetime'; + if (format === 'duration') return 'duration'; + if ((format === 'auto' || format === 'relative') && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) { + const tense = this.tense; + if (tense === 'past' || tense === 'future') return 'relative'; + if (elapsedMs < this.#thresholdMs) return 'relative'; + } + return 'datetime'; + } + + #getDurationFormat(duration: Duration): string { + const locale = this.#lang; + const style = this.formatStyle; + const tense = this.tense; + if ((tense === 'past' && duration.sign !== -1) || (tense === 'future' && duration.sign !== 1)) { + duration = emptyDuration; + } + const d = duration.blank ? emptyDuration : duration.abs(); + if (typeof Intl !== 'undefined' && (Intl as any).DurationFormat) { + const opts: Record = {style}; + if (duration.blank) opts.secondsDisplay = 'always'; + return new (Intl as any).DurationFormat(locale, opts).format({ + years: d.years, months: d.months, weeks: d.weeks, days: d.days, + hours: d.hours, minutes: d.minutes, seconds: d.seconds, + }); + } + // Fallback for browsers without Intl.DurationFormat + const parts: string[] = []; + for (const unit of unitNames) { + const value = d[`${unit}s` as keyof Duration] as number; + if (value || (duration.blank && unit === 'second')) { + try { + parts.push(new Intl.NumberFormat(locale, {style: 'unit', unit, unitDisplay: style} as Intl.NumberFormatOptions).format(value)); + } catch { // PaleMoon lacks Intl.NumberFormat unit style support + parts.push(`${value} ${value === 1 ? unit : `${unit}s`}`); + } + } + } + return parts.join(style === 'narrow' ? ' ' : ', '); + } + + #getRelativeFormat(duration: Duration): string { + const relativeFormat = new Intl.RelativeTimeFormat(this.#lang, { + numeric: 'auto', + style: this.formatStyle, + }); + const tense = this.tense; + if (tense === 'future' && duration.sign !== 1) duration = emptyDuration; + if (tense === 'past' && duration.sign !== -1) duration = emptyDuration; + const [int, unit] = getRelativeTimeUnit(duration); + if (unit === 'second' && int < 10) { + return relativeFormat.format(0, 'second'); + } + return relativeFormat.format(int, unit); + } + + #getDateTimeFormat(date: Date): string { + const formatter = new Intl.DateTimeFormat(this.#lang, { + second: this.second, + minute: this.minute, + hour: this.hour, + weekday: this.weekday, + day: this.day, + month: this.month, + year: this.year, + hourCycle: this.hour ? this.hourCycle as Intl.DateTimeFormatOptions['hourCycle'] : undefined, + }); + return `${this.prefix} ${formatter.format(date)}`.trim(); + } + + update(): void { + const date = this.date; + if (typeof Intl === 'undefined' || !Intl.DateTimeFormat || !date) { + dateObserver.unobserve(this); + return; + } + const now = Date.now(); + const tooltip = this.#getFormattedTitle(date); + if (tooltip && this.getAttribute('data-tooltip-content') !== tooltip) { + this.setAttribute('data-tooltip-content', tooltip); + this.setAttribute('aria-label', tooltip); + } + const elapsedMs = Math.abs(date.getTime() - now); + const duration = elapsedTime(date, now); + const format = this.#resolveFormat(elapsedMs); + let newText: string; + if (format === 'duration') { + newText = this.#getDurationFormat(duration); + } else if (format === 'relative') { + newText = this.#getRelativeFormat(duration); + } else { + newText = this.#getDateTimeFormat(date); + } + newText ||= (this.shadowRoot === this.#renderRoot && this.textContent) || ''; + if (this.#span.textContent !== newText) this.#span.textContent = newText; + if (format === 'relative' || format === 'duration') { + dateObserver.observe(this); + } else { + dateObserver.unobserve(this); + } + } +} + +window.customElements.define('relative-time', RelativeTime);