summaryrefslogtreecommitdiffstats
path: root/web_src/js/webcomponents
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/webcomponents')
-rw-r--r--web_src/js/webcomponents/README.md11
-rw-r--r--web_src/js/webcomponents/absolute-date.js40
-rw-r--r--web_src/js/webcomponents/absolute-date.test.js15
-rw-r--r--web_src/js/webcomponents/index.js5
-rw-r--r--web_src/js/webcomponents/origin-url.js22
-rw-r--r--web_src/js/webcomponents/origin-url.test.js17
-rw-r--r--web_src/js/webcomponents/overflow-menu.js220
-rw-r--r--web_src/js/webcomponents/polyfills.js17
8 files changed, 347 insertions, 0 deletions
diff --git a/web_src/js/webcomponents/README.md b/web_src/js/webcomponents/README.md
new file mode 100644
index 0000000..45af58e
--- /dev/null
+++ b/web_src/js/webcomponents/README.md
@@ -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.
diff --git a/web_src/js/webcomponents/absolute-date.js b/web_src/js/webcomponents/absolute-date.js
new file mode 100644
index 0000000..d2be455
--- /dev/null
+++ b/web_src/js/webcomponents/absolute-date.js
@@ -0,0 +1,40 @@
+import {Temporal} from 'temporal-polyfill';
+
+export function toAbsoluteLocaleDate(dateStr, lang, opts) {
+ return Temporal.PlainDate.from(dateStr).toLocaleString(lang ?? [], opts);
+}
+
+window.customElements.define('absolute-date', class extends HTMLElement {
+ static observedAttributes = ['date', 'year', 'month', 'weekday', 'day'];
+
+ update = () => {
+ const year = this.getAttribute('year') ?? '';
+ const month = this.getAttribute('month') ?? '';
+ const weekday = this.getAttribute('weekday') ?? '';
+ const day = this.getAttribute('day') ?? '';
+ const lang = this.closest('[lang]')?.getAttribute('lang') ||
+ this.ownerDocument.documentElement.getAttribute('lang') || '';
+
+ // only use the first 10 characters, e.g. the `yyyy-mm-dd` part
+ const dateStr = this.getAttribute('date').substring(0, 10);
+
+ if (!this.shadowRoot) this.attachShadow({mode: 'open'});
+ this.shadowRoot.textContent = toAbsoluteLocaleDate(dateStr, lang, {
+ ...(year && {year}),
+ ...(month && {month}),
+ ...(weekday && {weekday}),
+ ...(day && {day}),
+ });
+ };
+
+ attributeChangedCallback(_name, oldValue, newValue) {
+ if (!this.initialized || oldValue === newValue) return;
+ this.update();
+ }
+
+ connectedCallback() {
+ this.initialized = false;
+ this.update();
+ this.initialized = true;
+ }
+});
diff --git a/web_src/js/webcomponents/absolute-date.test.js b/web_src/js/webcomponents/absolute-date.test.js
new file mode 100644
index 0000000..ba04451
--- /dev/null
+++ b/web_src/js/webcomponents/absolute-date.test.js
@@ -0,0 +1,15 @@
+import {toAbsoluteLocaleDate} from './absolute-date.js';
+
+test('toAbsoluteLocaleDate', () => {
+ expect(toAbsoluteLocaleDate('2024-03-15', 'en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })).toEqual('March 15, 2024');
+
+ expect(toAbsoluteLocaleDate('2024-03-15', 'de-DE', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })).toEqual('15. März 2024');
+});
diff --git a/web_src/js/webcomponents/index.js b/web_src/js/webcomponents/index.js
new file mode 100644
index 0000000..7cec9da
--- /dev/null
+++ b/web_src/js/webcomponents/index.js
@@ -0,0 +1,5 @@
+import './polyfills.js';
+import '@github/relative-time-element';
+import './origin-url.js';
+import './overflow-menu.js';
+import './absolute-date.js';
diff --git a/web_src/js/webcomponents/origin-url.js b/web_src/js/webcomponents/origin-url.js
new file mode 100644
index 0000000..09aa77f
--- /dev/null
+++ b/web_src/js/webcomponents/origin-url.js
@@ -0,0 +1,22 @@
+// Convert an absolute or relative URL to an absolute URL with the current origin. It only
+// processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'.
+// NOTE: Keep this function in sync with clone_script.tmpl
+export function toOriginUrl(urlStr) {
+ try {
+ if (urlStr.startsWith('http://') || urlStr.startsWith('https://') || urlStr.startsWith('/')) {
+ const {origin, protocol, hostname, port} = window.location;
+ const url = new URL(urlStr, origin);
+ url.protocol = protocol;
+ url.hostname = hostname;
+ url.port = port || (protocol === 'https:' ? '443' : '80');
+ return url.toString();
+ }
+ } catch {}
+ return urlStr;
+}
+
+window.customElements.define('origin-url', class extends HTMLElement {
+ connectedCallback() {
+ this.textContent = toOriginUrl(this.getAttribute('data-url'));
+ }
+});
diff --git a/web_src/js/webcomponents/origin-url.test.js b/web_src/js/webcomponents/origin-url.test.js
new file mode 100644
index 0000000..3b2ab89
--- /dev/null
+++ b/web_src/js/webcomponents/origin-url.test.js
@@ -0,0 +1,17 @@
+import {toOriginUrl} from './origin-url.js';
+
+test('toOriginUrl', () => {
+ const oldLocation = window.location;
+ for (const origin of ['https://example.com', 'https://example.com:3000']) {
+ window.location = new URL(`${origin}/`);
+ expect(toOriginUrl('/')).toEqual(`${origin}/`);
+ expect(toOriginUrl('/org/repo.git')).toEqual(`${origin}/org/repo.git`);
+ expect(toOriginUrl('https://another.com')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com/')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com/org/repo.git')).toEqual(`${origin}/org/repo.git`);
+ expect(toOriginUrl('https://another.com:4000')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com:4000/')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com:4000/org/repo.git')).toEqual(`${origin}/org/repo.git`);
+ }
+ window.location = oldLocation;
+});
diff --git a/web_src/js/webcomponents/overflow-menu.js b/web_src/js/webcomponents/overflow-menu.js
new file mode 100644
index 0000000..a69ce16
--- /dev/null
+++ b/web_src/js/webcomponents/overflow-menu.js
@@ -0,0 +1,220 @@
+import {throttle} from 'throttle-debounce';
+import {createTippy} from '../modules/tippy.js';
+import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
+import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
+
+window.customElements.define('overflow-menu', class extends HTMLElement {
+ updateItems = throttle(100, () => {
+ if (!this.tippyContent) {
+ const div = document.createElement('div');
+ div.classList.add('tippy-target');
+ div.tabIndex = '-1'; // for initial focus, programmatic focus only
+ div.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab') {
+ const items = this.tippyContent.querySelectorAll('[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.click();
+ }
+ } else if (e.key === 'ArrowDown') {
+ if (document.activeElement?.matches('.tippy-target')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.querySelector('[role="menuitem"]:first-of-type').focus();
+ } else if (document.activeElement?.matches('[role="menuitem"]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.nextElementSibling?.focus();
+ }
+ } else if (e.key === 'ArrowUp') {
+ if (document.activeElement?.matches('.tippy-target')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.querySelector('[role="menuitem"]:last-of-type').focus();
+ } else if (document.activeElement?.matches('[role="menuitem"]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.previousElementSibling?.focus();
+ }
+ }
+ });
+ this.append(div);
+ this.tippyContent = div;
+ }
+
+ // move items in tippy back into the menu items for subsequent measurement
+ for (const item of this.tippyItems || []) {
+ this.menuItemsEl.append(item);
+ }
+
+ // measure which items are partially outside the element and move them into the button menu
+ this.tippyItems = [];
+ const menuRight = this.offsetLeft + this.offsetWidth;
+ const menuItems = this.menuItemsEl.querySelectorAll('.item');
+ const settingItem = this.menuItemsEl.querySelector('#settings-btn');
+ for (const item of menuItems) {
+ const itemRight = item.offsetLeft + item.offsetWidth;
+ // Width of the settings button plus a small value to get the next item to the left if there is directly one
+ // If no setting button is in the menu the default threshold is 38 - roughly the width of .overflow-menu-button
+ const overflowBtnThreshold = 38;
+ const threshold = settingItem?.offsetWidth ?? overflowBtnThreshold;
+ // If we have a settings item on the right-hand side, we must also check if the first,
+ // possibly overflowing item would still fit on the left-hand side of the overflow menu
+ // If not, it must be added to the array (twice). The duplicate is removed with the shift.
+ if (settingItem && !this.tippyItems?.length && item !== settingItem && menuRight - itemRight < overflowBtnThreshold) {
+ this.tippyItems.push(settingItem);
+ }
+ if (menuRight - itemRight < threshold) {
+ this.tippyItems.push(item);
+ }
+ }
+
+ // Special handling for settings button on right. Only done if a setting item is present
+ if (settingItem) {
+ // If less than 2 items overflow, remove all items (only settings "overflowed" - because it's on the right side)
+ if (this.tippyItems?.length < 2) {
+ this.tippyItems = [];
+ } else {
+ // Remove the first item of the list, because we have always one item more in the array due to the big threshold above
+ this.tippyItems.shift();
+ }
+ }
+
+ // 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();
+ 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);
+ return;
+ }
+
+ // create button initially
+ const btn = document.createElement('button');
+ btn.classList.add('overflow-menu-button', 'btn', 'tw-px-2', 'hover:tw-text-text-dark');
+ btn.setAttribute('aria-label', window.config.i18n.more_items);
+ btn.innerHTML = octiconKebabHorizontal;
+ this.append(btn);
+ this.button = btn;
+
+ createTippy(btn, {
+ trigger: 'click',
+ hideOnClick: true,
+ interactive: true,
+ placement: 'bottom-end',
+ role: 'menu',
+ content: this.tippyContent,
+ onShow: () => { // FIXME: onShown doesn't work (never be called)
+ setTimeout(() => {
+ this.tippyContent.focus();
+ }, 0);
+ },
+ });
+ });
+
+ 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);
+ }
+
+ 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('.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) {
+ 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();
+ }
+});
diff --git a/web_src/js/webcomponents/polyfills.js b/web_src/js/webcomponents/polyfills.js
new file mode 100644
index 0000000..38f50fa
--- /dev/null
+++ b/web_src/js/webcomponents/polyfills.js
@@ -0,0 +1,17 @@
+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, options) {
+ if (options.style === 'unit') {
+ return {
+ format(value) {
+ return ` ${value} ${options.unit}`;
+ },
+ };
+ }
+ return intlNumberFormat(locales, options);
+ };
+}