diff options
Diffstat (limited to 'web_src/js/webcomponents')
-rw-r--r-- | web_src/js/webcomponents/README.md | 11 | ||||
-rw-r--r-- | web_src/js/webcomponents/absolute-date.js | 40 | ||||
-rw-r--r-- | web_src/js/webcomponents/absolute-date.test.js | 15 | ||||
-rw-r--r-- | web_src/js/webcomponents/index.js | 5 | ||||
-rw-r--r-- | web_src/js/webcomponents/origin-url.js | 22 | ||||
-rw-r--r-- | web_src/js/webcomponents/origin-url.test.js | 17 | ||||
-rw-r--r-- | web_src/js/webcomponents/overflow-menu.js | 220 | ||||
-rw-r--r-- | web_src/js/webcomponents/polyfills.js | 17 |
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); + }; +} |