gitea source for verification 2026-05-22
This commit is contained in:
11
web_src/js/webcomponents/README.md
Normal file
11
web_src/js/webcomponents/README.md
Normal file
@@ -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 `webpack.config.js` so they work correctly in Vue.
|
||||
26
web_src/js/webcomponents/absolute-date.test.ts
Normal file
26
web_src/js/webcomponents/absolute-date.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {toAbsoluteLocaleDate} from './absolute-date.ts';
|
||||
|
||||
test('toAbsoluteLocaleDate', () => {
|
||||
expect(toAbsoluteLocaleDate('2024-03-15', 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})).toEqual('March 15, 2024');
|
||||
|
||||
expect(toAbsoluteLocaleDate('2024-03-15T01:02:03', 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})).toEqual('15. März 2024');
|
||||
|
||||
// these cases shouldn't happen
|
||||
expect(toAbsoluteLocaleDate('2024-03-15 01:02:03', '', {})).toEqual('Invalid Date');
|
||||
expect(toAbsoluteLocaleDate('10000-01-01', '', {})).toEqual('Invalid Date');
|
||||
|
||||
// test different timezone
|
||||
const oldTZ = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
expect(new Date('2024-03-15').toLocaleString('en-US')).toEqual('3/14/2024, 8:00:00 PM');
|
||||
expect(toAbsoluteLocaleDate('2024-03-15', 'en-US')).toEqual('3/15/2024, 12:00:00 AM');
|
||||
process.env.TZ = oldTZ;
|
||||
});
|
||||
39
web_src/js/webcomponents/absolute-date.ts
Normal file
39
web_src/js/webcomponents/absolute-date.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export function toAbsoluteLocaleDate(date: string, lang?: string, opts?: Intl.DateTimeFormatOptions) {
|
||||
// only use the date part, it is guaranteed to be in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ) or (YYYY-MM-DD)
|
||||
// if there is an "Invalid Date" error, there must be something wrong in code and should be fixed.
|
||||
// TODO: there is a root problem in backend code: the date "YYYY-MM-DD" is passed to backend without timezone (eg: deadline),
|
||||
// then backend parses it in server's timezone and stores the parsed timestamp into database.
|
||||
// If the user's timezone is different from the server's, the date might be displayed in the wrong day.
|
||||
const dateSep = date.indexOf('T');
|
||||
date = dateSep === -1 ? date : date.substring(0, dateSep);
|
||||
return new Date(`${date}T00:00:00`).toLocaleString(lang || [], opts);
|
||||
}
|
||||
|
||||
window.customElements.define('absolute-date', class extends HTMLElement {
|
||||
static observedAttributes = ['date', 'year', 'month', 'weekday', 'day'];
|
||||
|
||||
initialized = false;
|
||||
|
||||
update = () => {
|
||||
const opt: Record<string, string> = {};
|
||||
for (const attr of ['year', 'month', 'weekday', 'day']) {
|
||||
if (this.getAttribute(attr)) opt[attr] = this.getAttribute(attr);
|
||||
}
|
||||
const lang = this.closest('[lang]')?.getAttribute('lang') ||
|
||||
this.ownerDocument.documentElement.getAttribute('lang') || '';
|
||||
|
||||
if (!this.shadowRoot) this.attachShadow({mode: 'open'});
|
||||
this.shadowRoot.textContent = toAbsoluteLocaleDate(this.getAttribute('date'), lang, opt);
|
||||
};
|
||||
|
||||
attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null) {
|
||||
if (!this.initialized || oldValue === newValue) return;
|
||||
this.update();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.initialized = false;
|
||||
this.update();
|
||||
this.initialized = true;
|
||||
}
|
||||
});
|
||||
5
web_src/js/webcomponents/index.ts
Normal file
5
web_src/js/webcomponents/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import './polyfills.ts';
|
||||
import '@github/relative-time-element';
|
||||
import './origin-url.ts';
|
||||
import './overflow-menu.ts';
|
||||
import './absolute-date.ts';
|
||||
7
web_src/js/webcomponents/origin-url.ts
Normal file
7
web_src/js/webcomponents/origin-url.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {toOriginUrl} from '../utils/url.ts';
|
||||
|
||||
window.customElements.define('origin-url', class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.textContent = toOriginUrl(this.getAttribute('data-url'));
|
||||
}
|
||||
});
|
||||
242
web_src/js/webcomponents/overflow-menu.ts
Normal file
242
web_src/js/webcomponents/overflow-menu.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import {throttle} from 'throttle-debounce';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
|
||||
|
||||
window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
tippyContent: HTMLDivElement;
|
||||
tippyItems: Array<HTMLElement>;
|
||||
button: HTMLButtonElement;
|
||||
menuItemsEl: HTMLElement;
|
||||
resizeObserver: ResizeObserver;
|
||||
mutationObserver: MutationObserver;
|
||||
lastWidth: number;
|
||||
|
||||
updateButtonActivationState() {
|
||||
if (!this.button || !this.tippyContent) return;
|
||||
this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active')));
|
||||
}
|
||||
|
||||
updateItems = throttle(100, () => {
|
||||
if (!this.tippyContent) {
|
||||
const div = document.createElement('div');
|
||||
div.tabIndex = -1; // for initial focus, programmatic focus only
|
||||
div.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const items = this.tippyContent.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.button._tippy.hide();
|
||||
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?.matches('.tippy-target')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.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?.matches('.tippy-target')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.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();
|
||||
}
|
||||
}
|
||||
});
|
||||
div.classList.add('tippy-target');
|
||||
this.handleItemClick(div, '.tippy-target > .item');
|
||||
this.tippyContent = div;
|
||||
} // end if: no tippyContent 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 tippy back into the menu items for subsequent measurement
|
||||
for (const item of this.tippyItems || []) {
|
||||
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.tippyItems = [];
|
||||
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.tippyItems.length === 0;
|
||||
const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
|
||||
const moveToPopup = !onlyLastItem || !lastItemFit;
|
||||
if (moveToPopup) this.tippyItems.push(item);
|
||||
}
|
||||
}
|
||||
itemFlexSpace?.style.removeProperty('display');
|
||||
itemOverFlowMenuButton?.style.removeProperty('display');
|
||||
|
||||
// if there are no overflown items, remove any previously created button
|
||||
if (!this.tippyItems?.length) {
|
||||
const btn = this.querySelector('.overflow-menu-button');
|
||||
btn?._tippy?.destroy();
|
||||
btn?.remove();
|
||||
this.button = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// remove aria role from items that moved from tippy to menu
|
||||
for (const item of menuItems) {
|
||||
if (!this.tippyItems.includes(item)) {
|
||||
item.removeAttribute('role');
|
||||
}
|
||||
}
|
||||
|
||||
// move all items that overflow into tippy
|
||||
for (const item of this.tippyItems) {
|
||||
item.setAttribute('role', 'menuitem');
|
||||
this.tippyContent.append(item);
|
||||
}
|
||||
|
||||
// update existing tippy
|
||||
if (this.button?._tippy) {
|
||||
this.button._tippy.setContent(this.tippyContent);
|
||||
this.updateButtonActivationState();
|
||||
return;
|
||||
}
|
||||
|
||||
// create button initially
|
||||
this.button = document.createElement('button');
|
||||
this.button.classList.add('overflow-menu-button');
|
||||
this.button.setAttribute('aria-label', window.config.i18n.more_items);
|
||||
this.button.innerHTML = octiconKebabHorizontal;
|
||||
this.append(this.button);
|
||||
createTippy(this.button, {
|
||||
trigger: 'click',
|
||||
hideOnClick: true,
|
||||
interactive: true,
|
||||
placement: 'bottom-end',
|
||||
role: 'menu',
|
||||
theme: 'menu',
|
||||
content: this.tippyContent,
|
||||
onShow: () => { // FIXME: onShown doesn't work (never be called)
|
||||
setTimeout(() => {
|
||||
this.tippyContent.focus();
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
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.button?._tippy?.hide();
|
||||
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();
|
||||
}
|
||||
});
|
||||
7
web_src/js/webcomponents/polyfill.test.ts
Normal file
7
web_src/js/webcomponents/polyfill.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {weakRefClass} from './polyfills.ts';
|
||||
|
||||
test('polyfillWeakRef', () => {
|
||||
const WeakRef = weakRefClass();
|
||||
const r = new WeakRef(123);
|
||||
expect(r.deref()).toEqual(123);
|
||||
});
|
||||
34
web_src/js/webcomponents/polyfills.ts
Normal file
34
web_src/js/webcomponents/polyfills.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
// @ts-expect-error - polyfill is incomplete
|
||||
Intl.NumberFormat = function(locales: string | string[], options: Intl.NumberFormatOptions) {
|
||||
if (options.style === 'unit') {
|
||||
return {
|
||||
format(value: number | bigint | string) {
|
||||
return ` ${value} ${options.unit}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
return intlNumberFormat(locales, options);
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user