初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Web Components
|
||||
|
||||
This `webcomponents` directory contains the source code for the web components used in the Gitea Web UI.
|
||||
|
||||
https://developer.mozilla.org/en-US/docs/Web/Web_Components
|
||||
|
||||
# Guidelines
|
||||
|
||||
* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
|
||||
* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
|
||||
* All our components must be added to `vite.config.ts` so they work correctly in Vue.
|
||||
@@ -0,0 +1,14 @@
|
||||
import './polyfills.ts';
|
||||
import './relative-time.ts';
|
||||
import './overflow-menu.ts';
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
|
||||
function initPageThemeDarkLight() {
|
||||
// Set page's theme color preference as early as possible, to avoid flicker of wrong theme color during page load.
|
||||
const sync = () => document.documentElement.setAttribute('data-gitea-theme-dark', String(isDarkTheme()));
|
||||
sync();
|
||||
// Track system theme changes in case Gitea is using "auto" theme.
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', sync);
|
||||
}
|
||||
|
||||
initPageThemeDarkLight();
|
||||
@@ -0,0 +1,266 @@
|
||||
import {throttle} from 'throttle-debounce';
|
||||
import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
|
||||
|
||||
window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
popup!: HTMLDivElement;
|
||||
overflowItems: Array<HTMLElement> = [];
|
||||
button: HTMLButtonElement | null = null;
|
||||
menuItemsEl!: HTMLElement;
|
||||
resizeObserver!: ResizeObserver;
|
||||
mutationObserver!: MutationObserver;
|
||||
lastWidth!: number;
|
||||
|
||||
updateButtonActivationState() {
|
||||
if (!this.button || !this.popup) return;
|
||||
this.button.classList.toggle('active', Boolean(this.popup.querySelector('.item.active')));
|
||||
}
|
||||
|
||||
showPopup() {
|
||||
if (!this.popup || this.popup.style.display !== 'none') return;
|
||||
this.popup.style.display = '';
|
||||
this.button!.setAttribute('aria-expanded', 'true');
|
||||
setTimeout(() => this.popup.focus(), 0);
|
||||
document.addEventListener('click', this.onClickOutside, true);
|
||||
}
|
||||
|
||||
hidePopup() {
|
||||
if (!this.popup || this.popup.style.display === 'none') return;
|
||||
this.popup.style.display = 'none';
|
||||
this.button?.setAttribute('aria-expanded', 'false');
|
||||
document.removeEventListener('click', this.onClickOutside, true);
|
||||
}
|
||||
|
||||
onClickOutside = (e: Event) => {
|
||||
if (!this.popup?.contains(e.target as Node) && !this.button?.contains(e.target as Node)) {
|
||||
this.hidePopup();
|
||||
}
|
||||
};
|
||||
|
||||
updateItems = throttle(100, () => {
|
||||
if (!this.popup) {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('overflow-menu-popup');
|
||||
div.setAttribute('role', 'menu');
|
||||
div.tabIndex = -1; // for initial focus, programmatic focus only
|
||||
div.style.display = 'none';
|
||||
div.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === 'Tab') {
|
||||
const items = this.popup.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === items[0]) {
|
||||
e.preventDefault();
|
||||
items[items.length - 1].focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === items[items.length - 1]) {
|
||||
e.preventDefault();
|
||||
items[0].focus();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.hidePopup();
|
||||
this.button?.focus();
|
||||
} else if (e.key === ' ' || e.code === 'Enter') {
|
||||
if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(document.activeElement as HTMLElement).click();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (document.activeElement === this.popup) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.popup.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus();
|
||||
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (document.activeElement === this.popup) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.popup.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus();
|
||||
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(document.activeElement.previousElementSibling as HTMLElement)?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.handleItemClick(div, '.overflow-menu-popup > .item');
|
||||
this.popup = div;
|
||||
} // end if: no popup and create a new one
|
||||
|
||||
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
|
||||
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
|
||||
|
||||
// move items in popup back into the menu items for subsequent measurement
|
||||
for (const item of this.overflowItems) {
|
||||
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
|
||||
this.menuItemsEl.append(item);
|
||||
} else {
|
||||
itemFlexSpace.insertAdjacentElement('beforebegin', item);
|
||||
}
|
||||
}
|
||||
|
||||
// measure which items are partially outside the element and move them into the button menu
|
||||
// flex space and overflow menu are excluded from measurement
|
||||
itemFlexSpace?.style.setProperty('display', 'none', 'important');
|
||||
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
|
||||
this.overflowItems = [];
|
||||
const menuRight = this.offsetLeft + this.offsetWidth;
|
||||
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
|
||||
let afterFlexSpace = false;
|
||||
for (const [idx, item] of menuItems.entries()) {
|
||||
if (item.classList.contains('item-flex-space')) {
|
||||
afterFlexSpace = true;
|
||||
continue;
|
||||
}
|
||||
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
|
||||
const itemRight = item.offsetLeft + item.offsetWidth;
|
||||
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
|
||||
const onlyLastItem = idx === menuItems.length - 1 && this.overflowItems.length === 0;
|
||||
const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
|
||||
const moveToPopup = !onlyLastItem || !lastItemFit;
|
||||
if (moveToPopup) this.overflowItems.push(item);
|
||||
}
|
||||
}
|
||||
itemFlexSpace?.style.removeProperty('display');
|
||||
itemOverFlowMenuButton?.style.removeProperty('display');
|
||||
|
||||
// if there are no overflown items, remove any previously created button
|
||||
if (!this.overflowItems?.length) {
|
||||
this.hidePopup();
|
||||
this.button?.remove();
|
||||
this.popup?.remove();
|
||||
this.button = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// remove aria role from items that moved from popup to menu
|
||||
for (const item of menuItems) {
|
||||
if (!this.overflowItems.includes(item)) {
|
||||
item.removeAttribute('role');
|
||||
}
|
||||
}
|
||||
|
||||
// move all items that overflow into popup
|
||||
for (const item of this.overflowItems) {
|
||||
item.setAttribute('role', 'menuitem');
|
||||
this.popup.append(item);
|
||||
}
|
||||
|
||||
// update existing popup
|
||||
if (this.button) {
|
||||
this.updateButtonActivationState();
|
||||
return;
|
||||
}
|
||||
|
||||
// create button and attach popup
|
||||
const popupId = generateElemId('overflow-popup-');
|
||||
this.popup.id = popupId;
|
||||
|
||||
this.button = document.createElement('button');
|
||||
this.button.classList.add('overflow-menu-button');
|
||||
this.button.setAttribute('aria-label', window.config.i18n.more_items);
|
||||
this.button.setAttribute('aria-haspopup', 'true');
|
||||
this.button.setAttribute('aria-expanded', 'false');
|
||||
this.button.setAttribute('aria-controls', popupId);
|
||||
this.button.innerHTML = octiconKebabHorizontal;
|
||||
this.button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.popup.style.display === 'none') {
|
||||
this.showPopup();
|
||||
} else {
|
||||
this.hidePopup();
|
||||
}
|
||||
});
|
||||
this.append(this.button);
|
||||
this.append(this.popup);
|
||||
this.updateButtonActivationState();
|
||||
});
|
||||
|
||||
init() {
|
||||
// for horizontal menus where fomantic boldens active items, prevent this bold text from
|
||||
// enlarging the menu's active item replacing the text node with a div that renders a
|
||||
// invisible pseudo-element that enlarges the box.
|
||||
if (this.matches('.ui.secondary.pointing.menu, .ui.tabular.menu')) {
|
||||
for (const item of this.querySelectorAll('.item')) {
|
||||
for (const child of item.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent?.trim(); // whitespace is insignificant inside flexbox
|
||||
if (!text) continue;
|
||||
const span = document.createElement('span');
|
||||
span.classList.add('resize-for-semibold');
|
||||
span.setAttribute('data-text', text);
|
||||
span.textContent = text;
|
||||
child.replaceWith(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which
|
||||
// also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon.
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = entry.contentBoxSize[0].inlineSize;
|
||||
if (newWidth !== this.lastWidth) {
|
||||
requestAnimationFrame(() => {
|
||||
this.updateItems();
|
||||
});
|
||||
this.lastWidth = newWidth;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.resizeObserver.observe(this);
|
||||
this.handleItemClick(this, '.overflow-menu-items > .item');
|
||||
}
|
||||
|
||||
handleItemClick(el: Element, selector: string) {
|
||||
addDelegatedEventListener(el, 'click', selector, () => {
|
||||
this.hidePopup();
|
||||
this.updateButtonActivationState();
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.setAttribute('role', 'navigation');
|
||||
|
||||
// check whether the mandatory `.overflow-menu-items` element is present initially which happens
|
||||
// with Vue which renders differently than browsers. If it's not there, like in the case of browser
|
||||
// template rendering, wait for its addition.
|
||||
// The eslint rule is not sophisticated enough or aware of this problem, see
|
||||
// https://github.com/43081j/eslint-plugin-wc/pull/130
|
||||
const menuItemsEl = this.querySelector<HTMLElement>('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback
|
||||
if (menuItemsEl) {
|
||||
this.menuItemsEl = menuItemsEl;
|
||||
this.init();
|
||||
} else {
|
||||
this.mutationObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes as NodeListOf<HTMLElement>) {
|
||||
if (!isDocumentFragmentOrElementNode(node)) continue;
|
||||
if (node.classList.contains('overflow-menu-items')) {
|
||||
this.menuItemsEl = node;
|
||||
this.mutationObserver?.disconnect();
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.mutationObserver.observe(this, {childList: true});
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.mutationObserver?.disconnect();
|
||||
this.resizeObserver?.disconnect();
|
||||
document.removeEventListener('click', this.onClickOutside, true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {weakRefClass} from './polyfills.ts';
|
||||
|
||||
test('polyfillWeakRef', () => {
|
||||
const WeakRef = weakRefClass();
|
||||
const r = new WeakRef(123);
|
||||
expect(r.deref()).toEqual(123);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export function weakRefClass() {
|
||||
const weakMap = new WeakMap();
|
||||
return class {
|
||||
constructor(target: any) {
|
||||
weakMap.set(this, target);
|
||||
}
|
||||
deref() {
|
||||
return weakMap.get(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.WeakRef) {
|
||||
window.WeakRef = weakRefClass() as any;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import './relative-time.ts';
|
||||
|
||||
function createRelativeTime(datetime: string, attrs: Record<string, string> = {}): 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('accepts unix seconds as integer string', async () => {
|
||||
const el = createRelativeTime(String(Math.floor(Date.now() / 1000) - 3 * 60));
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('3 minutes ago');
|
||||
});
|
||||
|
||||
test('ignores fractional unix seconds', async () => {
|
||||
const el = createRelativeTime('1700000000.5');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('ignores negative unix seconds', async () => {
|
||||
const el = createRelativeTime('-86400');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('ignores invalid datetime', async () => {
|
||||
const el = createRelativeTime('bogus');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('ignores partial numeric datetime', async () => {
|
||||
const el = createRelativeTime('123abc');
|
||||
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('falls back when navigator.language is invalid', async () => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined');
|
||||
try {
|
||||
const el = document.createElement('relative-time');
|
||||
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString());
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('3 minutes ago');
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
// 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)?)?$/;
|
||||
const unixSecondsRe = /^\d+$/;
|
||||
|
||||
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<RelativeTime>();
|
||||
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 {
|
||||
for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
return String(new Intl.Locale(candidate));
|
||||
} catch {}
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
set prefix(v: string) {
|
||||
this.setAttribute('prefix', v);
|
||||
}
|
||||
|
||||
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') || '';
|
||||
}
|
||||
|
||||
set datetime(v: string) {
|
||||
this.setAttribute('datetime', v);
|
||||
}
|
||||
|
||||
get date(): Date | null {
|
||||
const dt = this.datetime;
|
||||
const parsed = unixSecondsRe.test(dt) ? Number(dt) * 1000 : Date.parse(dt);
|
||||
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<string, string> = {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}).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);
|
||||
Reference in New Issue
Block a user