From 256a1eeb9a67b18c62a10f5909b584b7b220848a Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 15 Mar 2024 03:05:31 +0100 Subject: Add ``, rename webcomponents (#29400) 1. Add `` web component 2. Rename `` to `` and make filenames match. image Screenshot 2024-03-02 at 21 36 52 Screenshot 2024-03-03 at 03 05 06 ![image](https://github.com/go-gitea/gitea/assets/115237/0f43770c-834c-4a05-8e3d-d30eb8653786) ![image](https://github.com/go-gitea/gitea/assets/115237/4b4c6bd7-843f-4f49-808f-6b3aed5e9f9a) TODO: - [x] Check if removal of `requestAnimationFrame` is possible to avoid flash of content. Likely needs a `MutationObserver`. - [x] Hide tippy when button is removed from DOM. - [x] ~~Implement right-aligned items (https://github.com/go-gitea/gitea/pull/28976)~~. Not going to do it. - [x] Clean up CSS so base element has no background and add background via tailwind instead. - [x] Use it for org and user page. --------- Co-authored-by: Giteabot Co-authored-by: wxiaoguang --- web_src/css/base.css | 69 +-------- web_src/css/modules/tippy.css | 29 +++- web_src/css/repo.css | 10 -- web_src/css/repo/header.css | 11 -- web_src/css/repo/linebutton.css | 5 - web_src/css/shared/repoorg.css | 7 - web_src/js/components/DashboardRepoList.vue | 62 +++++--- web_src/js/modules/tippy.js | 14 +- web_src/js/webcomponents/GiteaAbsoluteDate.js | 40 ------ web_src/js/webcomponents/GiteaOriginUrl.js | 22 --- web_src/js/webcomponents/GiteaOriginUrl.test.js | 17 --- web_src/js/webcomponents/README.md | 7 +- web_src/js/webcomponents/absolute-date.js | 40 ++++++ web_src/js/webcomponents/index.js | 5 + web_src/js/webcomponents/origin-url.js | 22 +++ web_src/js/webcomponents/origin-url.test.js | 17 +++ web_src/js/webcomponents/overflow-menu.js | 179 ++++++++++++++++++++++++ web_src/js/webcomponents/polyfill.js | 17 --- web_src/js/webcomponents/polyfills.js | 17 +++ web_src/js/webcomponents/webcomponents.js | 6 - 20 files changed, 364 insertions(+), 232 deletions(-) delete mode 100644 web_src/js/webcomponents/GiteaAbsoluteDate.js delete mode 100644 web_src/js/webcomponents/GiteaOriginUrl.js delete mode 100644 web_src/js/webcomponents/GiteaOriginUrl.test.js create mode 100644 web_src/js/webcomponents/absolute-date.js create mode 100644 web_src/js/webcomponents/index.js create mode 100644 web_src/js/webcomponents/origin-url.js create mode 100644 web_src/js/webcomponents/origin-url.test.js create mode 100644 web_src/js/webcomponents/overflow-menu.js delete mode 100644 web_src/js/webcomponents/polyfill.js create mode 100644 web_src/js/webcomponents/polyfills.js delete mode 100644 web_src/js/webcomponents/webcomponents.js (limited to 'web_src') diff --git a/web_src/css/base.css b/web_src/css/base.css index 1c6b3fa488..510a28ad9f 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -248,7 +248,7 @@ a.label, } .page-content .header-wrapper, -.page-content .new-menu { +.page-content overflow-menu { margin-top: -15px !important; padding-top: 15px !important; } @@ -1353,75 +1353,21 @@ strong.attention-caution, span.attention-caution { } } -.ui.menu.new-menu { - margin-bottom: 15px; - background: var(--color-header-wrapper); +overflow-menu { + margin-bottom: 15px !important; border-bottom: 1px solid var(--color-secondary) !important; - overflow: auto; + display: flex; } -.ui.menu.new-menu .new-menu-inner { +overflow-menu .overflow-menu-items { display: flex; - margin-left: auto; - margin-right: auto; - overflow-x: auto; - width: 100%; - mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 60px), transparent 100%); - -webkit-mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 60px), transparent 100%); + flex: 1; } -.ui.menu.new-menu .item { +overflow-menu .overflow-menu-items .item { margin-bottom: 0 !important; /* reset fomantic's margin, because the active menu has special bottom border */ } -@media (max-width: 767.98px) { - .ui.menu.new-menu .item { - width: auto !important; - } -} - -.ui.menu.new-menu .item:first-child { - margin-left: auto; /* "justify-content: center" doesn't work with "overflow: auto", so use margin: auto */ -} - -.ui.menu.new-menu .item:last-child { - padding-right: 30px !important; - margin-right: auto; -} - -.ui.menu.new-menu::-webkit-scrollbar { - height: 6px; - display: none; -} - -.ui.menu.new-menu::-webkit-scrollbar-track { - background: none !important; -} - -.ui.menu.new-menu::-webkit-scrollbar-thumb { - box-shadow: none !important; -} - -.ui.menu.new-menu:hover::-webkit-scrollbar { - display: block; -} - -.repos-search { - padding-bottom: 0 !important; -} - -.repos-filter { - margin-top: 0 !important; - border-bottom-width: 0 !important; - margin-bottom: 2px !important; - justify-content: space-evenly; -} - -.ui.secondary.pointing.menu.repos-filter .item { - padding-left: 4.5px; - padding-right: 4.5px; -} - .activity-bar-graph { background-color: var(--color-primary); color: var(--color-primary-contrast); @@ -1927,7 +1873,6 @@ table th[data-sortt-desc] .svg { background: var(--color-body); border-color: var(--color-secondary); color: var(--color-text); - margin-top: 1px; /* offset fomantic's margin-bottom: -1px */ } .ui.segment .ui.tabular.menu .active.item, diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css index d65ecc89fb..76d36b4293 100644 --- a/web_src/css/modules/tippy.css +++ b/web_src/css/modules/tippy.css @@ -5,6 +5,11 @@ display: none !important; } +/* show target element once it's been moved by tippy.js */ +.tippy-content .tippy-target { + display: unset !important; +} + [data-tippy-root] { max-width: calc(100vw - 32px); } @@ -46,18 +51,40 @@ .tippy-box[data-theme="menu"] { background-color: var(--color-menu); color: var(--color-text); + box-shadow: 0 6px 18px var(--color-shadow); } .tippy-box[data-theme="menu"] .tippy-content { - padding: 0; + padding: 4px 0; } .tippy-box[data-theme="menu"] .tippy-svg-arrow-inner { fill: var(--color-menu); } +.tippy-box[data-theme="menu"] .item { + display: flex; + align-items: center; + padding: 9px 18px; + color: inherit; + text-decoration: none; + gap: 10px; +} + +.tippy-box[data-theme="menu"] .item:hover { + background: var(--color-hover); +} + +.tippy-box[data-theme="menu"] .item:focus { + background: var(--color-active); +} + /* box-with-header theme to look like .ui.attached.segment. can contain .ui.attached.header */ +.tippy-box[data-theme="box-with-header"] { + box-shadow: 0 6px 18px var(--color-shadow); +} + .tippy-box[data-theme="box-with-header"] .tippy-content { background: var(--color-box-body); border-radius: var(--border-radius); diff --git a/web_src/css/repo.css b/web_src/css/repo.css index c9c27acf34..23b4e94a06 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2787,16 +2787,6 @@ tbody.commit-list { border-left: 1px solid var(--color-secondary); } -.repository .ui.menu.new-menu { - background: none !important; -} - -@media (max-width: 1200px) { - .repository .ui.menu.new-menu::after { - background: none !important; - } -} - .migrate-entries { display: grid !important; grid-template-columns: repeat(3, 1fr); diff --git a/web_src/css/repo/header.css b/web_src/css/repo/header.css index 0eb03136ef..4461e3338e 100644 --- a/web_src/css/repo/header.css +++ b/web_src/css/repo/header.css @@ -74,17 +74,6 @@ background-color: var(--color-header-wrapper); } -.repository .header-wrapper .new-menu { - padding-top: 0 !important; - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.repository .header-wrapper .new-menu .item { - margin-left: 0 !important; - margin-right: 0 !important; -} - @media (max-width: 767.98px) { .repo-header .flex-item { flex-grow: 1; diff --git a/web_src/css/repo/linebutton.css b/web_src/css/repo/linebutton.css index 1e5e51eac5..79be5a7a9e 100644 --- a/web_src/css/repo/linebutton.css +++ b/web_src/css/repo/linebutton.css @@ -2,11 +2,6 @@ color: var(--color-text-dark) !important; } -.code-line-menu { - width: auto !important; - border: none !important; /* the border is provided by tippy, not using the `.ui.menu` border */ -} - .code-line-button { background-color: var(--color-menu); color: var(--color-text-light); diff --git a/web_src/css/shared/repoorg.css b/web_src/css/shared/repoorg.css index 7f0a805d0f..5573ae47b8 100644 --- a/web_src/css/shared/repoorg.css +++ b/web_src/css/shared/repoorg.css @@ -5,13 +5,6 @@ margin-left: 15px; } -.repository .ui.secondary.stackable.pointing.menu, -.organization .ui.secondary.stackable.pointing.menu { - flex-wrap: wrap; - margin-top: 5px; - margin-bottom: 10px; -} - .repository .ui.tabs.container, .organization .ui.tabs.container { margin-top: 14px; diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index 2e8f335ce5..b9ee531d2a 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -384,28 +384,30 @@ export default sfc; // activate the IDE's Vue plugin - + + +
    @@ -501,6 +503,22 @@ ul li:not(:last-child) { border-bottom: 1px solid var(--color-secondary); } +.repos-search { + padding-bottom: 0 !important; +} + +.repos-filter { + padding-top: 0 !important; + margin-top: 0 !important; + border-bottom-width: 0 !important; + margin-bottom: 2px !important; +} + +.repos-filter .item { + padding-left: 6px !important; + padding-right: 6px !important; +} + .repo-list-link { min-width: 0; /* for text truncation */ display: flex; diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js index 489afc0ae1..e7eb39f457 100644 --- a/web_src/js/modules/tippy.js +++ b/web_src/js/modules/tippy.js @@ -7,7 +7,8 @@ const visibleInstances = new Set(); export function createTippy(target, opts = {}) { // the callback functions should be destructured from opts, // because we should use our own wrapper functions to handle them, do not let the user override them - const {onHide, onShow, onDestroy, ...other} = opts; + const {onHide, onShow, onDestroy, role, theme, ...other} = opts; + const instance = tippy(target, { appendTo: document.body, animation: false, @@ -35,17 +36,14 @@ export function createTippy(target, opts = {}) { return onShow?.(instance); }, arrow: ``, - role: 'menu', // HTML role attribute, only tooltips should use "tooltip" - theme: other.role || 'menu', // CSS theme, either "tooltip", "menu" or "box-with-header" + role: role || 'menu', // HTML role attribute + theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu" or "box-with-header" plugins: [followCursor], ...other, }); - // for popups where content refers to a DOM element, we use the 'tippy-target' class - // to initially hide the content, now we can remove it as the content has been removed - // from the DOM by tippy - if (other.content instanceof Element) { - other.content.classList.remove('tippy-target'); + if (role === 'menu') { + target.setAttribute('aria-haspopup', 'true'); } return instance; diff --git a/web_src/js/webcomponents/GiteaAbsoluteDate.js b/web_src/js/webcomponents/GiteaAbsoluteDate.js deleted file mode 100644 index 660aa99d07..0000000000 --- a/web_src/js/webcomponents/GiteaAbsoluteDate.js +++ /dev/null @@ -1,40 +0,0 @@ -window.customElements.define('gitea-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 extract the `yyyy-mm-dd` part. When converting to Date, it will become midnight UTC and when rendered - // as localized date, will have a offset towards UTC, which we remove to shift the timestamp to midnight in the - // localized date. We should eventually use `Temporal.PlainDate` which will make the correction unnecessary. - // - https://stackoverflow.com/a/14569783/808699 - // - https://tc39.es/proposal-temporal/docs/plaindate.html - const date = new Date(this.getAttribute('date').substring(0, 10)); - const correctedDate = new Date(date.getTime() - date.getTimezoneOffset() * -60000); - - if (!this.shadowRoot) this.attachShadow({mode: 'open'}); - this.shadowRoot.textContent = correctedDate.toLocaleString(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/GiteaOriginUrl.js b/web_src/js/webcomponents/GiteaOriginUrl.js deleted file mode 100644 index 6e6f84d739..0000000000 --- a/web_src/js/webcomponents/GiteaOriginUrl.js +++ /dev/null @@ -1,22 +0,0 @@ -// 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('gitea-origin-url', class extends HTMLElement { - connectedCallback() { - this.textContent = toOriginUrl(this.getAttribute('data-url')); - } -}); diff --git a/web_src/js/webcomponents/GiteaOriginUrl.test.js b/web_src/js/webcomponents/GiteaOriginUrl.test.js deleted file mode 100644 index f0629842b8..0000000000 --- a/web_src/js/webcomponents/GiteaOriginUrl.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import {toOriginUrl} from './GiteaOriginUrl.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/README.md b/web_src/js/webcomponents/README.md index 0fde507310..45af58e1d2 100644 --- a/web_src/js/webcomponents/README.md +++ b/web_src/js/webcomponents/README.md @@ -6,7 +6,6 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components # Guidelines -* These components are loaded in `` (before DOM body), - so they should have their own dependencies and should be very light, - then they won't affect the page loading time too much. -* If the component is not a public one, it's suggested to have its own `Gitea` or `gitea-` prefix to avoid conflicts. +* These components are loaded in `` (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 0000000000..d12ea0a437 --- /dev/null +++ b/web_src/js/webcomponents/absolute-date.js @@ -0,0 +1,40 @@ +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 extract the `yyyy-mm-dd` part. When converting to Date, it will become midnight UTC and when rendered + // as localized date, will have a offset towards UTC, which we remove to shift the timestamp to midnight in the + // localized date. We should eventually use `Temporal.PlainDate` which will make the correction unnecessary. + // - https://stackoverflow.com/a/14569783/808699 + // - https://tc39.es/proposal-temporal/docs/plaindate.html + const date = new Date(this.getAttribute('date').substring(0, 10)); + const correctedDate = new Date(date.getTime() - date.getTimezoneOffset() * -60000); + + if (!this.shadowRoot) this.attachShadow({mode: 'open'}); + this.shadowRoot.textContent = correctedDate.toLocaleString(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/index.js b/web_src/js/webcomponents/index.js new file mode 100644 index 0000000000..7cec9da734 --- /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 0000000000..09aa77f2c0 --- /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 0000000000..3b2ab89f2a --- /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 0000000000..9fa4585567 --- /dev/null +++ b/web_src/js/webcomponents/overflow-menu.js @@ -0,0 +1,179 @@ +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'); + for (const item of menuItems) { + const itemRight = item.offsetLeft + item.offsetWidth; + if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button + this.tippyItems.push(item); + } + } + + // 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() { + // 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/polyfill.js b/web_src/js/webcomponents/polyfill.js deleted file mode 100644 index 88c7276881..0000000000 --- a/web_src/js/webcomponents/polyfill.js +++ /dev/null @@ -1,17 +0,0 @@ -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); - }; -} diff --git a/web_src/js/webcomponents/polyfills.js b/web_src/js/webcomponents/polyfills.js new file mode 100644 index 0000000000..88c7276881 --- /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); + }; +} diff --git a/web_src/js/webcomponents/webcomponents.js b/web_src/js/webcomponents/webcomponents.js deleted file mode 100644 index 03348d895f..0000000000 --- a/web_src/js/webcomponents/webcomponents.js +++ /dev/null @@ -1,6 +0,0 @@ -import '@webcomponents/custom-elements'; // polyfill for some browsers like PaleMoon -import './polyfill.js'; - -import '@github/relative-time-element'; -import './GiteaOriginUrl.js'; -import './GiteaAbsoluteDate.js'; -- cgit v1.2.3