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