feat(ui): render the scene sharing dialogs and bar from a store

createSceneDialog keeps its builder but publishes the dialog's lines,
fields, buttons and status to a scene sharing store; SceneDialog.vue,
teleported to the body from the scene panels view, draws the same modal
with the same styles and opens it with Cancel focused. The EDIT DETAILS /
SHARE SCENE bar is drawn by that view while sharing offers it. The status
directive is shared with Provider Settings, and the two QA scripts that
drove the old DOM read the new dialog.
This commit is contained in:
Sameh Khamis committed 2026-10-31 18:37:14 -07:00
1 parent fe963a1219
commit 9d9eb267c5
15 files changed
+842 -355

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
/**
* Write a live region's message, even a repeated one, so a screen reader
* announces it again. The value is `{text, seq}`; the owner bumps `seq` for
* every message it says.
*/
export const vStatusText = {
mounted(el, { value }) {
el.textContent = value.text;
},
updated(el, { value, oldValue }) {
if (value.seq !== oldValue?.seq || value.text !== oldValue?.text)
el.textContent = value.text;
},
};
-72
View File
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { createStateChannel } from '../app/stateChannel.js';
import { sceneElements } from './scenePresentation.js';
import { mountSceneSharing } from './sceneSharing.js';
import { setLocale } from '../i18n/index.js';
import { installVueDom } from '../testSupport/vueDom.mjs';
@@ -585,74 +584,3 @@ test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => {
f.restore();
}
});
test('The sharing row sits under SAVE & SHARE, after the file row', () => {
const saved = globalThis.document;
const node = (className = '') => {
const element = {
className,
children: [],
dataset: {},
parent: null,
addEventListener() {},
removeEventListener() {},
append(...items) {
for (const item of items) {
item.parent = element;
element.children.push(item);
}
},
appendChild(item) {
element.append(item);
},
after(item) {
const siblings = element.parent.children;
item.parent = element.parent;
siblings.splice(siblings.indexOf(element) + 1, 0, item);
},
remove() {
const siblings = element.parent?.children;
if (siblings) siblings.splice(siblings.indexOf(element), 1);
},
querySelector(selector) {
const name = selector.slice(1);
const walk = (at) => {
for (const child of at.children) {
if (child.className.split(' ').includes(name)) return child;
const found = walk(child);
if (found) return found;
}
return null;
};
return walk(element);
},
};
return element;
};
globalThis.document = { createElement: () => node() };
try {
const panel = node();
const inner = node('scene-panel-inner');
const fileRow = node('scene-controls scene-file-controls');
const status = node('scene-status');
panel.append(inner);
inner.append(fileRow, status);
const dispose = mountSceneSharing({ edit() {}, share() {} }, panel);
const bar = inner.children[1];
assert.equal(bar.className, 'scene-controls');
assertSameNode(inner.children[2], status);
assert.ok(bar.children.every((button) => button.title));
dispose();
assert.deepEqual(inner.children, [fileRow, status]);
// Without the file row, the bar still lands inside the panel.
const plain = node();
const plainInner = node('scene-panel-inner');
plain.append(plainInner);
mountSceneSharing({ edit() {}, share() {} }, plain)();
mountSceneSharing({ edit() {}, share() {} }, plain);
assert.equal(plainInner.children.length, 1);
} finally {
globalThis.document = saved;
}
});
+147 -186
View File
@@ -1,210 +1,171 @@
import { t, tn } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
import { t } from '../i18n/index.js';
import { useSceneSharingStore } from './stores/sceneSharing.js';
import { applicationPinia } from './vueRuntime.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
const dialog = document.createElement('dialog');
dialog.dataset.directorDialog = '';
dialog.className = 'director-sharing-dialog';
dialog.setAttribute('aria-label', title);
Object.assign(dialog.style, {
background: '#08141e',
color: '#e3faff',
border: '1px solid #397080',
borderRadius: '6px',
padding: '20px',
width: 'min(720px,calc(100vw - 32px))',
boxSizing: 'border-box',
position: 'fixed',
inset: '0',
margin: 'auto',
maxHeight: 'calc(100vh - 32px)',
overflow: 'auto',
font: '14px/1.5 sans-serif',
});
const heading = document.createElement('h2');
heading.textContent = title;
heading.style.marginBottom = '12px';
dialog.append(heading);
const body = document.createElement('div'),
footer = document.createElement('div'),
status = document.createElement('p');
status.setAttribute('role', 'status');
dialog.append(body, status, footer);
const listeners = [];
function listen(node, type, fn) {
node.addEventListener(type, fn);
listeners.push(() => node.removeEventListener(type, fn));
}
function text(value, parent = body) {
const p = document.createElement('p');
p.textContent = value;
p.style.margin = '8px 0';
parent.append(p);
return p;
}
function button(label, fn, parent = footer) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
Object.assign(b.style, {
background: '#10242e',
color: '#e3faff',
border: '1px solid #397080',
borderRadius: '3px',
padding: '7px 11px',
margin: '6px 6px 6px 0',
cursor: 'pointer',
font: 'inherit',
let dialogCount = 0;
/**
* Small owned modal for scene authoring and import review; text never becomes
* markup. The dialog is presented through the scene sharing store and drawn
* by SceneDialog.vue; the returned builder adds its lines, fields and buttons
* in order. Body content comes first, then the status line, then the footer
* row, which starts with Cancel.
* @param {string} title
* @param {() => void} onClose Cancel, Escape.
*/
export function createSceneDialog(
title,
onClose,
store = useSceneSharingStore(applicationPinia()),
) {
const id = ++dialogCount;
const items = [];
const actions = new Map();
let status = '',
statusSeq = 0,
nextItem = 0,
disposed = false;
const controls = {
click(itemId) {
const item = items.find((entry) => entry.id === itemId);
if (!disposed && !item?.disabled) actions.get(itemId)?.();
},
setValue(itemId, value) {
const item = items.find((entry) => entry.id === itemId);
if (item) item.value = value;
},
setFiles(itemId, files) {
const item = items.find((entry) => entry.id === itemId);
if (!item) return;
item.files = [...files];
publish();
},
cancel() {
if (!disposed) onClose();
},
};
function publish() {
if (disposed || store.controls !== controls) return;
store.publishDialog({
id,
title,
items: items.map((item) => ({ ...item })),
status,
statusSeq,
});
listen(b, 'click', fn);
parent.append(b);
return b;
}
function input(label, value, { type = 'text', multiline = false } = {}) {
const wrapper = document.createElement('label');
wrapper.style.display = 'block';
wrapper.textContent = label;
const node = document.createElement(multiline ? 'textarea' : 'input');
if (!multiline) node.type = type;
node.value = value;
node.setAttribute('aria-label', label);
Object.assign(node.style, {
display: 'block',
boxSizing: 'border-box',
width: '100%',
padding: '7px',
margin: '6px 0 14px',
color: '#e3faff',
background: '#10242e',
border: '1px solid #397080',
font: multiline ? '12px monospace' : 'inherit',
});
if (multiline) node.rows = 9;
wrapper.append(node);
if (type === 'file') fileChooser(wrapper, node);
body.append(wrapper);
return node;
function add(item) {
items.push({ id: nextItem++, ...item });
publish();
return items.at(-1);
}
function text(value) {
add({ kind: 'text', place: 'body', value });
}
/**
* The browser draws a file input's button and "no file" text in its own
* language. Stretch the native input, transparent, over the whole row (so
* click, focus and drops stay native) and draw translated text under it.
* @param {string} label
* @param {() => void} fn
* @param {object} [options]
* @param {'body'|'footer'} [options.place]
* @param {string} [options.hook] A data attribute name, e.g.
* 'data-director-apply-import', for automation to find the button by.
* @returns {{disabled: boolean}}
*/
function fileChooser(wrapper, node) {
wrapper.style.position = 'relative';
Object.assign(node.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
margin: '0',
padding: '0',
border: '0',
opacity: '0',
cursor: 'pointer',
zIndex: '1',
function button(label, fn, { place = 'footer', hook, autofocus } = {}) {
const item = add({
kind: 'button',
place,
label,
disabled: false,
hook,
autofocus,
});
const row = document.createElement('span');
row.setAttribute('aria-hidden', 'true');
Object.assign(row.style, {
display: 'flex',
alignItems: 'center',
gap: '10px',
margin: '6px 0 14px',
padding: '7px',
background: '#10242e',
border: '1px solid #397080',
});
const chooser = document.createElement('span');
chooser.textContent = t('Choose files');
Object.assign(chooser.style, {
padding: '3px 9px',
border: '1px solid #397080',
borderRadius: '3px',
background: '#08141e',
cursor: 'pointer',
});
const chosen = document.createElement('span');
const showChosen = () => {
const count = node.files?.length || 0;
// One file shows its name, like the browser's own picker did.
chosen.textContent =
count === 1
? node.files[0].name
: count
? tn(count, '{count} file selected', '{count} files selected')
: t('No file chosen');
actions.set(item.id, fn);
return {
get disabled() {
return item.disabled;
},
set disabled(value) {
item.disabled = Boolean(value);
publish();
},
};
showChosen();
row.append(chooser, chosen);
wrapper.append(row);
listen(node, 'change', showChosen);
listen(node, 'focus', () => {
chooser.style.outline = '2px solid #e3faff';
});
listen(node, 'blur', () => {
chooser.style.outline = '';
});
}
button(t('Cancel'), onClose);
listen(dialog, 'cancel', (event) => {
event.preventDefault();
onClose();
});
document.body.append(dialog);
dialog.showModal();
/**
* @param {string} label
* @param {string} value
* @param {object} [options]
* @param {string} [options.type]
* @param {boolean} [options.multiline]
* @param {boolean} [options.multiple] File inputs: several files.
* @param {boolean} [options.directory] File inputs: a whole folder.
* @returns {{value: string, readonly files: File[]}}
*/
function input(
label,
value,
{ type = 'text', multiline = false, multiple, directory } = {},
) {
const item = add({
kind: 'input',
place: 'body',
label,
value,
type: multiline ? null : type,
multiline,
multiple: Boolean(multiple),
directory: Boolean(directory),
files: [],
});
return {
get value() {
return item.value;
},
set value(next) {
item.value = next;
publish();
},
get files() {
return item.files;
},
};
}
function setStatus(value) {
status = value;
statusSeq++;
publish();
}
store.connect(controls);
// Cancel takes the opening focus, as the first control drawn.
button(t('Cancel'), onClose, { autofocus: true });
return {
body,
footer,
status,
text,
button,
input,
listen,
element: dialog,
setStatus,
dispose() {
for (const remove of listeners.splice(0)) remove();
dialog.close();
dialog.remove();
if (disposed) return;
if (store.controls === controls) {
store.connect(null);
store.publishDialog(null);
}
disposed = true;
},
};
}
/** Add controls only when their owner is present; dispose removes every listener and element. */
/**
* Offer the EDIT DETAILS / SHARE SCENE bar beside the scene panel's file
* controls; the returned function withdraws it, unless another owner has
* offered its own since.
*/
export function mountSceneSharing(
{ edit, share },
panel = document.getElementById('scene-panel'),
store = useSceneSharingStore(applicationPinia()),
) {
if (!panel) return () => {};
const bar = document.createElement('div');
bar.dataset.directorAuthoring = '';
bar.className = 'scene-controls';
const entries = [
[t('EDIT DETAILS'), t('Edit anchors, data packs and shot details'), edit],
[
t('SHARE SCENE'),
t('Download this scene as JSON or an asset bundle'),
share,
],
];
const removers = [];
for (const [text, title, fn] of entries) {
const b = createActionButton({
label: text,
title,
className: 'scene-btn',
});
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
}
// Beside EXPORT / IMPORT, under SAVE & SHARE.
const fileRow = panel.querySelector?.('.scene-file-controls');
if (fileRow) fileRow.after(bar);
else (panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
const actions = { edit, share };
store.offerToolbar(actions);
return () => {
for (const remove of removers) remove();
bar.remove();
if (store.toolbar === actions) store.offerToolbar(null);
};
}
+254
View File
@@ -0,0 +1,254 @@
// src/ui/sceneSharingView.test.mjs — scene sharing's authoring bar and its
// dialog render from the scene sharing store, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useSceneSharingStore } from './stores/sceneSharing.js';
import { createSceneDialog, mountSceneSharing } from './sceneSharing.js';
import LayerPanelsView from './views/LayerPanelsView.vue';
// happy-dom may lack the modal methods; record what the view asks for.
const opened = [];
HTMLDialogElement.prototype.showModal ??= function () {
this.open = true;
};
const showModal = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = function () {
opened.push(this.getAttribute('aria-label'));
return showModal.call(this);
};
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(LayerPanelsView).use(pinia);
app.mount(host);
t.after(() => app.unmount());
return { store: useSceneSharingStore(pinia) };
}
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
const labels = (nodes) => nodes.map((node) => node.textContent);
test('the authoring bar sits under SAVE & SHARE, after the file row, while offered', async (t) => {
const { store } = fixture(t);
assert.equal($('[data-director-authoring]'), null);
const calls = [];
const withdraw = mountSceneSharing(
{ edit: () => calls.push('edit'), share: () => calls.push('share') },
store,
);
await nextTick();
const bar = $('[data-director-authoring]');
assert.equal(
bar.previousElementSibling.classList.contains('scene-file-controls'),
true,
);
assert.equal(bar.className, 'scene-controls');
const buttons = $$('[data-director-authoring] button');
assert.deepEqual(labels(buttons), ['EDIT DETAILS', 'SHARE SCENE']);
assert.ok(buttons.every((button) => button.title));
assert.ok(
buttons.every((button) => button.classList.contains('gev-action-button')),
);
buttons[0].click();
buttons[1].click();
assert.deepEqual(calls, ['edit', 'share']);
// A newer offer survives the older one's withdrawal.
const newer = mountSceneSharing({ edit() {}, share() {} }, store);
withdraw();
await nextTick();
assert.ok($('[data-director-authoring]'));
newer();
await nextTick();
assert.equal($('[data-director-authoring]'), null);
});
test('a dialog draws its lines, fields and buttons in order and opens modal', async (t) => {
const { store } = fixture(t);
opened.length = 0;
const clicks = [];
const dialog = createSceneDialog('Edit scene details', () => {}, store);
dialog.text('Drafts are validated.');
const details = dialog.input('Anchors', '{"a":1}', { multiline: true });
const anchor = dialog.input('New anchor ID', 'anchor-1');
dialog.button('Capture', () => clicks.push('capture'), { place: 'body' });
const apply = dialog.button('Apply details', () => clicks.push('apply'), {
hook: 'data-director-apply-details',
});
await nextTick();
const root = $('dialog[data-director-dialog]');
assert.equal(root.parentElement, document.body);
assert.equal(root.getAttribute('aria-label'), 'Edit scene details');
assert.equal(root.classList.contains('director-sharing-dialog'), true);
assert.deepEqual(opened, ['Edit scene details']);
assert.equal($('dialog h2').textContent, 'Edit scene details');
const [body, status, footer] = [...root.children].slice(1);
assert.equal(status.getAttribute('role'), 'status');
assert.deepEqual(
[...body.children].map((node) => node.tagName),
['P', 'LABEL', 'LABEL', 'BUTTON'],
);
// Cancel comes first in the footer and takes the opening focus.
assert.deepEqual(labels([...footer.children]), ['Cancel', 'Apply details']);
assert.equal(footer.children[0].hasAttribute('autofocus'), true);
assert.equal(footer.children[1].hasAttribute('autofocus'), false);
assert.equal(
footer.children[1].hasAttribute('data-director-apply-details'),
true,
);
const textarea = $('dialog textarea');
assert.equal(textarea.value, '{"a":1}');
assert.equal(textarea.getAttribute('aria-label'), 'Anchors');
assert.equal($('dialog input').value, 'anchor-1');
textarea.value = '{"b":2}';
textarea.dispatchEvent(new Event('input', { bubbles: true }));
assert.equal(details.value, '{"b":2}');
anchor.value = 'anchor-2';
await nextTick();
assert.equal($('dialog input').value, 'anchor-2');
body.children[3].click();
footer.children[1].click();
assert.deepEqual(clicks, ['capture', 'apply']);
apply.disabled = true;
await nextTick();
assert.equal(footer.children[1].disabled, true);
assert.equal(apply.disabled, true);
// A disabled action does not run, even when asked directly.
store.controls.click(
store.dialog.items.find((item) => item.label === 'Apply details').id,
);
footer.children[1].click();
assert.deepEqual(clicks, ['capture', 'apply']);
// The owner's write reaches a focused field; typing is kept on republish.
textarea.focus();
details.value = '{"c":3}';
await nextTick();
assert.equal(textarea.value, '{"c":3}');
textarea.value = '{"d":4}';
textarea.dispatchEvent(new Event('input', { bubbles: true }));
dialog.setStatus('Anchor added to draft');
await nextTick();
assert.equal(textarea.value, '{"d":4}');
assert.equal(details.value, '{"d":4}');
});
test('the status line re-announces a repeated message', async (t) => {
const { store } = fixture(t);
const dialog = createSceneDialog('Review scene import', () => {}, store);
await nextTick();
const status = $('dialog [role=status]');
assert.equal(status.textContent, '');
const writes = [];
const text = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
Object.defineProperty(status, 'textContent', {
configurable: true,
get: () => text.get.call(status),
set(value) {
writes.push(value);
text.set.call(status, value);
},
});
dialog.setStatus('Ready to import');
await nextTick();
dialog.setStatus('Ready to import');
await nextTick();
assert.deepEqual(writes, ['Ready to import', 'Ready to import']);
});
test('Cancel and Escape close through the owner; dispose removes the dialog', async (t) => {
const { store } = fixture(t);
let closes = 0;
const dialog = createSceneDialog(
'Share selected scene',
() => closes++,
store,
);
await nextTick();
$('dialog div:last-of-type button').click();
const cancel = new Event('cancel', { cancelable: true });
$('dialog').dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented, true);
assert.equal(closes, 2);
dialog.dispose();
await nextTick();
assert.equal($('dialog'), null);
assert.equal(store.controls, null);
});
test('a replaced dialog opens fresh and its late dispose leaves the new one', async (t) => {
const { store } = fixture(t);
opened.length = 0;
const first = createSceneDialog('Review scene import', () => {}, store);
await nextTick();
const firstRoot = $('dialog');
const second = createSceneDialog('Edit scene details', () => {}, store);
first.dispose();
first.text('stale');
await nextTick();
assert.equal($$('dialog').length, 1);
assert.equal($('dialog') === firstRoot, false);
assert.equal($('dialog').getAttribute('aria-label'), 'Edit scene details');
assert.deepEqual(opened, ['Review scene import', 'Edit scene details']);
assert.equal(
$$('dialog p').some((p) => p.textContent === 'stale'),
false,
);
second.dispose();
});
test('a file field draws translated chooser text over a native input', async (t) => {
const { store } = fixture(t);
const dialog = createSceneDialog('Share selected scene', () => {}, store);
const files = dialog.input('Choose data-pack files', '', {
type: 'file',
multiple: true,
});
dialog.input('Or choose a data-pack folder', '', {
type: 'file',
multiple: true,
directory: true,
});
await nextTick();
const [pick, folder] = $$('dialog input[type=file]');
assert.equal(pick.multiple, true);
assert.equal(pick.hasAttribute('webkitdirectory'), false);
assert.equal(folder.hasAttribute('webkitdirectory'), true);
assert.equal(pick.style.opacity, '0');
const chosen = () =>
pick.parentElement.querySelector('[aria-hidden] span:last-child')
.textContent;
assert.equal(chosen(), 'No file chosen');
const one = new File(['x'], 'pack.json');
Object.defineProperty(pick, 'files', { configurable: true, value: [one] });
pick.dispatchEvent(new Event('change', { bubbles: true }));
await nextTick();
assert.equal(chosen(), 'pack.json');
assert.equal(files.files[0] === one, true);
Object.defineProperty(pick, 'files', {
configurable: true,
value: [one, new File(['y'], 'b.json')],
});
pick.dispatchEvent(new Event('change', { bubbles: true }));
await nextTick();
assert.equal(chosen(), '2 files selected');
assert.equal(files.files.length, 2);
const chooser = pick.parentElement.querySelector('[aria-hidden] span');
pick.dispatchEvent(new FocusEvent('focus'));
await nextTick();
assert.notEqual(chooser.style.outline, '');
pick.dispatchEvent(new FocusEvent('blur'));
await nextTick();
assert.equal(chooser.style.outline, '');
dialog.dispose();
});
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* Scene sharing as its views render it. The sharing owner
* (src/scenes/sharing.js) offers the EDIT DETAILS / SHARE SCENE bar and
* presents its one open dialog here; the views send clicks, typing and file
* choices back to the connected dialog.
*/
export const useSceneSharingStore = defineStore('gev-scene-sharing', () => {
/** The bar's actions ({edit, share}), or null while no owner offers it. */
const toolbar = shallowRef(null);
/** @param {?{edit: Function, share: Function}} next */
function offerToolbar(next) {
toolbar.value = next && markRaw(next);
}
/** The open dialog's presentation, or null while none is open. */
const dialog = shallowRef(null);
/** @param {?object} next A fresh presentation; every publish re-renders. */
function publishDialog(next) {
dialog.value = next && markRaw(next);
}
/** The open dialog the view calls, or null while none is wired. */
const controls = shallowRef(null);
/** @param {?object} next The dialog, or null to disconnect. */
function connect(next) {
controls.value = next && markRaw(next);
}
return { toolbar, offerToolbar, dialog, publishDialog, controls, connect };
});
+15
View File
@@ -1,8 +1,16 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { useSceneSharingStore } from '../stores/sceneSharing.js';
import SceneDialog from './panels/SceneDialog.vue';
import SceneOptions from './SceneOptions.vue';
import SceneShotList from './SceneShotList.vue';
import CctvPanel from './panels/CctvPanel.vue';
import StreetLevelPanel from './panels/StreetLevelPanel.vue';
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
// its dialog through this store; without this view, neither is drawn.
const { toolbar, dialog } = storeToRefs(useSceneSharingStore());
</script>
<template>
@@ -61,6 +69,10 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" title="Download the timing log of the last play" data-i18n data-i18n-title>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div v-if="toolbar" data-director-authoring class="scene-controls">
<button type="button" class="gev-action-button scene-btn" :title="t('Edit anchors, data packs and shot details')" @click="toolbar.edit()">{{ t('EDIT DETAILS') }}</button>
<button type="button" class="gev-action-button scene-btn" :title="t('Download this scene as JSON or an asset bundle')" @click="toolbar.share()">{{ t('SHARE SCENE') }}</button>
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
@@ -69,4 +81,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
</div>
</div>
<Teleport to="body">
<SceneDialog v-if="dialog" :key="dialog.id" :view="dialog" />
</Teleport>
</template>
+2 -11
View File
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js';
import { vFieldValue } from '../rail/fieldValue.js';
import { vStatusText } from '../rail/statusText.js';
import { useLocaleStore } from '../stores/locale.js';
import { useProviderSettingsStore } from '../stores/providerSettings.js';
import LocalePicker from './panels/LocalePicker.vue';
@@ -34,17 +35,7 @@ onMounted(() => {
});
onBeforeUnmount(() => themeObserver?.disconnect());
// The status line is a live region: write each message, even a repeated one,
// so a screen reader announces it again.
const vStatusText = {
mounted(el, { value }) {
el.textContent = value.text;
},
updated(el, { value, oldValue }) {
if (value.seq !== oldValue?.seq || value.text !== oldValue?.text)
el.textContent = value.text;
},
};
// The status line is a live region; vStatusText re-announces repeats.
const statusText = computed(() => ({
text:
view.value?.status ??
+64
View File
@@ -0,0 +1,64 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue';
import { t, tn } from '../../../i18n/index.js';
import { vStatusText } from '../../rail/statusText.js';
import { useSceneSharingStore } from '../../stores/sceneSharing.js';
// One open scene sharing dialog, as src/ui/sceneSharing.js presents it. The
// parent keys this by dialog, so each one opens as a fresh modal.
const props = defineProps({ view: { type: Object, required: true } });
const { controls } = storeToRefs(useSceneSharingStore());
const root = useTemplateRef('root');
onMounted(() => root.value?.showModal?.());
onBeforeUnmount(() => {
if (root.value?.open) root.value.close();
});
const bodyItems = computed(() => props.view.items.filter((item) => item.place === 'body'));
const footerItems = computed(() => props.view.items.filter((item) => item.place === 'footer'));
// Typing reaches the item before any republish, so a field is rewritten only
// when the owner changes its value, focused or not, as the old code did.
const statusText = computed(() => ({ text: props.view.status, seq: props.view.statusSeq }));
// The browser draws a file input's button and "no file" text in its own
// language. The native input is stretched, transparent, over the whole row
// (so click, focus and drops stay native) and translated text is drawn under.
const focusedFile = shallowRef(null);
const chosenText = (item) => {
const count = item.files.length;
// One file shows its name, like the browser's own picker did.
return count === 1
? item.files[0].name
: count
? tn(count, '{count} file selected', '{count} files selected')
: t('No file chosen');
};
const onFiles = (item, event) => controls.value?.setFiles(item.id, event.target.files ?? []);
const dialogStyle = { background: '#08141e', color: '#e3faff', border: '1px solid #397080', borderRadius: '6px', padding: '20px', width: 'min(720px,calc(100vw - 32px))', boxSizing: 'border-box', position: 'fixed', inset: '0', margin: 'auto', maxHeight: 'calc(100vh - 32px)', overflow: 'auto', font: '14px/1.5 sans-serif' };
const buttonStyle = { background: '#10242e', color: '#e3faff', border: '1px solid #397080', borderRadius: '3px', padding: '7px 11px', margin: '6px 6px 6px 0', cursor: 'pointer', font: 'inherit' };
const fieldStyle = (multiline) => ({ display: 'block', boxSizing: 'border-box', width: '100%', padding: '7px', margin: '6px 0 14px', color: '#e3faff', background: '#10242e', border: '1px solid #397080', font: multiline ? '12px monospace' : 'inherit' });
const fileInputStyle = { ...fieldStyle(false), position: 'absolute', inset: '0', width: '100%', height: '100%', margin: '0', padding: '0', border: '0', opacity: '0', cursor: 'pointer', zIndex: '1' };
const fileRowStyle = { display: 'flex', alignItems: 'center', gap: '10px', margin: '6px 0 14px', padding: '7px', background: '#10242e', border: '1px solid #397080' };
const chooserStyle = (focused) => ({ padding: '3px 9px', border: '1px solid #397080', borderRadius: '3px', background: '#08141e', cursor: 'pointer', outline: focused ? '2px solid #e3faff' : '' });
</script>
<template>
<dialog ref="root" data-director-dialog class="director-sharing-dialog" :aria-label="view.title" :style="dialogStyle" @cancel.prevent="controls?.cancel()">
<h2 style="margin-bottom: 12px">{{ view.title }}</h2>
<div>
<template v-for="item in bodyItems" :key="item.id">
<p v-if="item.kind === 'text'" style="margin: 8px 0">{{ item.value }}</p>
<button v-else-if="item.kind === 'button'" type="button" :style="buttonStyle" :disabled="item.disabled" v-bind="item.hook ? { [item.hook]: '' } : {}" @click="controls?.click(item.id)">{{ item.label }}</button>
<label v-else-if="item.type === 'file'" :style="{ display: 'block', position: 'relative' }">{{ item.label }}<input type="file" :aria-label="item.label" :multiple="item.multiple" :webkitdirectory="item.directory || undefined" :style="fileInputStyle" @change="onFiles(item, $event)" @focus="focusedFile = item.id" @blur="focusedFile = null" /><span aria-hidden="true" :style="fileRowStyle"><span :style="chooserStyle(focusedFile === item.id)">{{ t('Choose files') }}</span><span>{{ chosenText(item) }}</span></span></label>
<label v-else style="display: block">{{ item.label }}<textarea v-if="item.multiline" rows="9" :aria-label="item.label" :style="fieldStyle(true)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)"></textarea><input v-else :type="item.type" :aria-label="item.label" :style="fieldStyle(false)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)" /></label>
</template>
</div>
<p role="status" v-status-text="statusText"></p>
<div>
<button v-for="item in footerItems" :key="item.id" type="button" :style="buttonStyle" :disabled="item.disabled" :autofocus="item.autofocus || undefined" v-bind="item.hook ? { [item.hook]: '' } : {}" @click="controls?.click(item.id)">{{ item.label }}</button>
</div>
</dialog>
</template>