openvibers/openvibe.shared Commits

4.109.2: a select inside a modal dialog or a fullscreen player opens over it (#287)

OP OpenVibers committed 9 days ago · cde5e55224 · 9 files, +55 −9

The list goes inside the dialog or fullscreen element the select is in, and into the top
layer (popover) where the browser has it.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cce6e52..8235b06 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.109.2 — 2026-11-03
++
++- **A select inside a modal dialog or a fullscreen player opens over it.** The list (menu or phone sheet) used to be
++ added to the body. A modal `<dialog>` such as the kit's sheets made it inert, and a fullscreen element hid it.
++ - It now goes inside the dialog or the fullscreen element the select is in.
++ - Where the browser supports popovers, it opens in the top layer, above any z-index.
++ - The browser's own popover box is undone.
++ - `test/ov-forms-chrome.test.js` covers a select in a modal dialog: the list is seen, a choice lands, the dialog
++ stays open.
++
## 4.109.1 — 2026-11-03
- **`data-ov-select="bare"`: a select inside a chip or a toolbar keeps that look.** It gets no box, chevron or badge of
ddiff --git a/README.md b/README.md
iindex 3cb3a31..dcd291f 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.109.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.109.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex ee4ce59..c7da86f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.109.1",
++ "version": "4.109.2",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/navbar.js b/navbar.js
iindex 9526593..8caa799 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -491,7 +491,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '2c505c7b96a3', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '80d19bb69142', 'ov-forms.css': '8b2504444998' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '2c505c7b96a3', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
ddiff --git a/ov-forms.css b/ov-forms.css
iindex 7abb39b..5acc302 100644
---- a/ov-forms.css
++++ b/ov-forms.css
@@@ -65,6 +65,11 @@
color: var(--ovf-text); background: var(--ovf-surface);
font-family: inherit; font-size: 14px; line-height: 1.35; text-align: start;
}
++/* In the top layer (4.109.2) the list is a popover: the browser's own popover box (centred, bordered, padded) is undone. */
++.ov-sel-pop[popover], .ov-sel-sheet[popover], .ov-sel-scrim[popover] { margin: 0; inset: auto; }
++.ov-sel-pop[popover] { padding: 6px; border: 1px solid var(--ovf-line); overflow: hidden; }
++.ov-sel-sheet[popover] { left: 0; right: 0; bottom: 0; width: auto; max-width: none; border: 0; }
++.ov-sel-scrim[popover] { inset: 0; width: auto; height: auto; padding: 0; border: 0; }
.ov-sel-pop *, .ov-sel-sheet *, .ov-sel-pop *::before, .ov-sel-sheet *::after { box-sizing: border-box; }
.ov-sel-pop {
position: fixed; max-width: min(460px, calc(100vw - 16px)); padding: 6px; overflow: hidden;
ddiff --git a/ov-forms.js b/ov-forms.js
iindex 9048715..b34edb7 100644
---- a/ov-forms.js
++++ b/ov-forms.js
@@@ -249,9 +249,10 @@
if (sheet) {
st.scrim = el('div', 'ov-sel-scrim');
st.scrim.addEventListener('click', () => close(true));
-- doc.body.append(st.scrim, wrap);
++ raise(st.scrim, st.btn);
++ raise(wrap, st.btn);
doc.documentElement.classList.add('ov-sel-locked');
-- } else doc.body.appendChild(wrap);
++ } else raise(wrap, st.btn);
draw(st);
st.btn.setAttribute('aria-expanded', 'true');
st.btn.setAttribute('aria-controls', `${st.id}-pop`);
@@@ -272,6 +273,21 @@
});
}
++ /**
++ * Put the list where it is seen and can be touched (4.109.2): inside the modal <dialog> or the fullscreen element
++ * the select is in (else the body), and in the top layer when the browser has popovers, so it opens over the
++ * dialog, a fullscreen player, and anything with a z-index.
++ */
++ const POPOVER = typeof root.HTMLElement === 'function' && typeof root.HTMLElement.prototype.showPopover === 'function';
++ function raise(node, from) {
++ // A modal dialog makes everything outside it inert, top layer or not: the list lives inside the dialog.
++ const host = (from && from.closest('dialog[open]')) || (doc.fullscreenElement && doc.fullscreenElement.contains(from) ? doc.fullscreenElement : null) || doc.body;
++ host.appendChild(node);
++ if (!POPOVER) return;
++ node.setAttribute('popover', 'manual');
++ try { node.showPopover(); } catch { node.removeAttribute('popover'); }
++ }
++
function last(st, items) { for (let k = items.length - 1; k >= 0; k--) if (!items[k].disabled) return items[k].i; return null; }
/** The rows that match the search, in groups when the select has them. */
@@@ -383,7 +399,8 @@
const wrap = st.wrap; const scrim = st.scrim;
st.wrap = null; st.list = null; st.scrim = null; st.input = null;
doc.documentElement.classList.remove('ov-sel-locked');
-- const gone = () => { if (wrap) wrap.remove(); if (scrim) scrim.remove(); };
++ const drop = (n) => { if (!n) return; try { if (n.matches(':popover-open')) n.hidePopover(); } catch { /* not a popover */ } n.remove(); };
++ const gone = () => { drop(wrap); drop(scrim); };
if (wrap) wrap.classList.remove('is-open');
if (scrim) scrim.classList.remove('is-open');
if (st.sheet && !reduce()) setTimeout(gone, 220); else gone();
ddiff --git a/package-lock.json b/package-lock.json
iindex bbd327d..f0b2921 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.109.1",
++ "version": "4.109.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.109.1",
++ "version": "4.109.2",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 4c41ba0..25c7e14 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.109.1",
++ "version": "4.109.2",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/ov-forms-chrome.test.js b/test/ov-forms-chrome.test.js
iindex 0e05a4b..94156a7 100644
---- a/test/ov-forms-chrome.test.js
++++ b/test/ov-forms-chrome.test.js
@@@ -34,6 +34,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta n
<label><input type="checkbox" role="switch" id="sw"> A switch</label>
<button id="send">Send</button>
</form>
++<dialog id="dlg"><label for="indlg">In a dialog</label><select id="indlg"><option>one</option><option>two</option></select></dialog>
<script>window.__changes = 0; document.getElementById('agent').addEventListener('change', () => window.__changes++);
document.getElementById('f').addEventListener('submit', (e) => { e.preventDefault(); const d = new FormData(e.target); window.__sent = { agent: d.get('agent'), many: d.getAll('many'), long: d.get('long') }; });</script>
<script src="/ov-forms.js"></script></body></html>`;
@@@ -125,6 +126,19 @@ const key = (k, target = 'document.activeElement') => `(${target}).dispatchEvent
assert.strictEqual(await p.evaluate(`${btn}.classList.contains('is-invalid')`), true, 'an empty required select marks its button');
assert.strictEqual(await p.evaluate(`document.activeElement === ${btn}`), true, 'and takes the focus');
++ // A select inside a modal dialog opens over it (the top layer), and can be chosen from.
++ await p.evaluate("document.getElementById('dlg').showModal()");
++ await wait(60);
++ await p.evaluate("document.querySelector('#indlg + .ov-sel').click()");
++ await wait(80);
++ const seen = await p.evaluate("(() => { const pop = document.querySelector('.ov-sel-pop'); if (!pop) return 'no pop'; const r = pop.getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return pop.contains(hit) ? true : JSON.stringify({ r: [r.left, r.top, r.width, r.height], open: pop.matches(':popover-open'), hit: hit && (hit.id || hit.className || hit.tagName), cs: getComputedStyle(pop).opacity }); })()");
++ assert.strictEqual(seen, true, `the list is seen over a modal dialog: ${seen}`);
++ await p.evaluate("document.querySelectorAll('.ov-sel-pop .ov-sel-opt')[1].click()");
++ await wait(60);
++ assert.strictEqual(await p.evaluate("document.getElementById('indlg').value"), 'two', 'and a choice in it lands');
++ assert.strictEqual(await p.evaluate("document.getElementById('dlg').open"), true, 'without closing the dialog');
++ await p.evaluate("document.getElementById('dlg').close()");
++
// Markup added later is enhanced too.
await p.evaluate("document.body.insertAdjacentHTML('beforeend', '<select id=\"late\"><option>x</option><option>y</option></select>')");
await wait(200);