feat(panels): build the rail panels on one UiPanel

The Data, Authoring, Weather, Recent Imagery, Context, CCTV, Street Level
and Radio panels each wrote the same frame by hand. They are now one kit
component, UiPanel, with a title, badge/actions/default slots and
v-model:collapsed, drawing Core's panel markup and classes beside the kit's;
props cover the differences (sideways, glow, scroll, railScroller, tag,
toggleWords). RailPanel binds it to the panel chrome store: v-panel-state
writes its state classes at once, and its button's clicks reach PanelChrome.
UiPanelToggle is the collapse button UiPanel and PanelCollapseButton share.

UiPanel's collapsed drives the button and ui-panel--collapsed
only; the owner hides the body by writing the root's collapsed class, since
the rails measure a panel in the task that opens it. PanelCollapseButton
takes startCollapsed instead of initial and initialTitle, so a button shows
its state's glyph and words before startup presents the panel. Titles render
through t() instead of data-i18n, and the unused "Expand panel" string is
pruned. v-panel-state redraws the presented state after a re-render, in
case a root's class binding ever changes.
This commit is contained in:
Sameh Khamis committed 2026-11-01 08:33:18 -08:00
1 parent a3fc475cbe
commit cd231faae9
30 files changed
+427 -197

No files matched your search

+3 -3
View File
@@ -97,11 +97,11 @@ test('Context async action buttons remain focused while busy', () => {
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
assert.ok(header, 'Global Context header is missing');
// The kit button's own ui-button and ui-focus classes carry no Core styles.
// The kit's own ui-* classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
`${tag}.${cls.split(' ').filter((name) => !/^ui-(button|focus)/.test(name)).join(' ')}`);
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
-1
View File
@@ -746,7 +746,6 @@
"Search any location...": "Ort suchen...",
"WEATHER": "WETTER",
"CONTEXT": "KONTEXT",
"Expand panel": "Bereich ausklappen",
"Context mode": "Kontextmodus",
"CONTACTS": "KONTAKTE",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Wechselt zwischen den nächstgelegenen Kontakten des gewählten Typs: Flugzeuge, Schiffe, Anlagen. Satelliten werden unabhängig verfolgt.",
-1
View File
@@ -176,7 +176,6 @@
"AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "SOLO DATOS DISPONIBLES · NO IMPLICA TODO DESPEJADO",
"Enable cockpit weather effects": "Activar los efectos climáticos de la cabina",
"CONTEXT": "CONTEXTO",
"Expand panel": "Expandir panel",
"Context mode": "Modo de contexto",
"CONTACTS": "CONTACTOS",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Recorre los contactos más cercanos del tipo que elijas — aeronaves, buques, instalaciones. Los satélites se rastrean por separado.",
-1
View File
@@ -2791,7 +2791,6 @@
"RECENT IMAGERY": "IMAGERIE RÉCENTE",
"Imagery days in the box": "Jours d'imagerie dans la zone",
"CONTEXT": "CONTEXTE",
"Expand panel": "Développer le panneau",
"Context mode": "Mode de contexte",
"CONTACTS": "CONTACTS",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Parcourt les contacts les plus proches du type sélectionné — avions, navires, installations. Les satellites sont suivis séparément.",
-1
View File
@@ -2791,7 +2791,6 @@
"RECENT IMAGERY": "IMMAGINI RECENTI",
"Imagery days in the box": "Giorni con immagini nel riquadro",
"CONTEXT": "CONTESTO",
"Expand panel": "Espandi il pannello",
"Context mode": "Modalità contesto",
"CONTACTS": "CONTATTI",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Scorre i contatti più vicini del tipo selezionato: aerei, navi, installazioni. I satelliti vengono tracciati in modo indipendente.",
-1
View File
@@ -2649,7 +2649,6 @@
"RECENT IMAGERY": "最近の衛星画像",
"Imagery days in the box": "範囲内の画像のある日",
"CONTEXT": "コンテキスト",
"Expand panel": "パネルを展開",
"Context mode": "コンテキストモード",
"CONTACTS": "コンタクト",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "選択した種類(航空機、船舶、施設)の最寄りのコンタクトを順に切り替えます。衛星は独立して追跡されます。",
-1
View File
@@ -2793,7 +2793,6 @@
"RECENT IMAGERY": "IMAGENS RECENTES",
"Imagery days in the box": "Dias com imagens na área",
"CONTEXT": "CONTEXTO",
"Expand panel": "Expandir painel",
"Context mode": "Modo de contexto",
"CONTACTS": "CONTATOS",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Percorre os contatos mais próximos do tipo que você selecionar — aviões, embarcações, instalações. Os satélites são rastreados de forma independente.",
-1
View File
@@ -707,7 +707,6 @@
"FLIR": "FLIR",
"Style": "Стиль",
"STATION SITE": "САЙТ СТАНЦИИ",
"Expand panel": "Развернуть панель",
"CONTACTS": "КОНТАКТЫ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
"SEARCH NEARBY SITES": "ПОИСК ОБЪЕКТОВ РЯДОМ",
-1
View File
@@ -2861,7 +2861,6 @@
"RECENT IMAGERY": "СВІЖІ ЗНІМКИ",
"Imagery days in the box": "Дні зі знімками в області",
"CONTEXT": "КОНТЕКСТ",
"Expand panel": "Розгорнути панель",
"Context mode": "Режим контексту",
"CONTACTS": "КОНТАКТИ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Гортає найближчі контакти вибраного вами типу — літаки, судна, обʼєкти. Супутники відстежуються окремо.",
-1
View File
@@ -2545,7 +2545,6 @@
"RECENT IMAGERY": "近期影像",
"Imagery days in the box": "视野内的影像天数",
"CONTEXT": "态势",
"Expand panel": "展开面板",
"Context mode": "态势模式",
"CONTACTS": "目标",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。",
-1
View File
@@ -2533,7 +2533,6 @@
"RECENT IMAGERY": "近期影像",
"Imagery days in the box": "視野內的影像天數",
"CONTEXT": "態勢",
"Expand panel": "展開面板",
"Context mode": "態勢模式",
"CONTACTS": "目標",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。",
+3 -3
View File
@@ -18,7 +18,7 @@ const layoutController = read('src/ui/panelLayoutController.js');
test('Street Level is a collapsible right-rail panel that starts collapsed', () => {
assert.match(
html,
/<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">/,
/<div id="street-level-panel" class="ui-panel panel-collapsible collapsed" data-panel-id="street-level-panel">/,
);
assert.match(
html,
@@ -26,7 +26,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
);
assert.match(
html,
/<span class="panel-title" data-i18n>STREET LEVEL<\/span>/,
/<span class="ui-panel__title panel-title">STREET LEVEL<\/span>/,
);
// Provider-neutral header: no vendor mark; the inner restores its scroll.
assert.doesNotMatch(html, /sl-mark|mly-mark/);
@@ -49,7 +49,7 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
// A right-rail panel's collapse button opens sideways (◀ / ▶).
assert.match(
read('src/ui/views/panels/StreetLevelPanel.vue'),
/<PanelCollapseButton[^>]*target="street-level-panel"[^>]*\bsideways\b/,
/<RailPanel id="street-level-panel"[^>]*\bsideways\b/,
);
assert.match(
layoutController,
+1 -1
View File
@@ -84,7 +84,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
);
assert.match(
html,
/<div id="cctv-panel"[^>]*class="panel-collapsible collapsed"/,
/<RailPanel id="cctv-panel"[^>]*\bstart-collapsed\b/,
'the CCTV panel starts collapsed in the markup',
);
+13 -3
View File
@@ -53,7 +53,10 @@ function fixture(t) {
test('before ContextControls renders, the switch shows its initial markup', (t) => {
const { $ } = fixture(t);
const panel = $('global-context-panel');
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(
panel.className,
'ui-panel panel-collapsible collapsed ui-panel--collapsed',
);
assert.equal(panel.hasAttribute('data-context-mode'), false);
for (const id of [
'global-context-flights-btn',
@@ -118,14 +121,21 @@ test('the panel carries the mode beside the classes its chrome writes', async (t
panel.classList.remove('collapsed');
controls._contextMode = 'flights';
await sync();
assert.equal(panel.className, 'panel-collapsible context-enabled');
// Without an owner the button, and the kit class, keep the start state.
assert.equal(
panel.className,
'ui-panel panel-collapsible ui-panel--collapsed context-enabled',
);
assert.equal(panel.getAttribute('data-context-mode'), 'flights');
assert.equal($('context-flights-view').hidden, false);
panel.classList.add('collapsed');
controls._contextMode = null;
await sync();
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(
panel.className,
'ui-panel panel-collapsible ui-panel--collapsed collapsed',
);
assert.equal(panel.getAttribute('data-context-mode'), 'none');
assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true);
+3 -1
View File
@@ -28,7 +28,9 @@ test('CCTV and Context keep their headers outside Cyber-only scroll bodies', ()
/class="cyber-panel-body ui-scroll" data-rail-scroller/,
);
assert.ok(
markup.indexOf('class="panel-header"') <
// The header is RailPanel's; the body is its first content.
markup.indexOf('<RailPanel ') > -1 &&
markup.indexOf('<RailPanel ') <
markup.indexOf('class="cyber-panel-body ui-scroll"'),
);
}
+74
View File
@@ -0,0 +1,74 @@
<script setup>
import { vClassToggle } from '../rail/classToggle.js';
import UiPanelToggle from './UiPanelToggle.vue';
// Core's panel, with @halfpixel/ui-vue UiPanel's name, title, slots and
// v-model:collapsed so the two kits can merge. It draws Core's panel markup
// (glow, inner, a header with the title, badges, divider, actions and the
// collapse button, then the body) and keeps Core's classes beside the kit's.
// Unlike ui-vue's, `collapsed` does not hide the body: the root's classes
// stay as rendered, and whatever owns the panel's state writes its
// `collapsed` class at once (PanelChrome, through vPanelState), because the
// rails measure a panel in the task that opens it. `collapsed` drives the
// button and the kit's `ui-panel--collapsed`, toggled without a re-render.
// There is no Close: Core's panels collapse.
const props = defineProps({
/** The root's id, its inner's class (`{id}-inner`) and the button's target. */
id: { type: String, required: true },
/** The heading, already translated; also the button's "Expand {name}". */
title: { type: String, required: true },
/** The heading's tip. */
hint: { type: String, default: undefined },
tag: { type: String, default: 'div' },
collapsible: { type: Boolean, default: false },
collapsed: { type: Boolean, default: false },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
sideways: { type: Boolean, default: false },
/** Words that replace "Expand/Collapse {title}" on the button. */
toggleWords: { type: Object, default: null },
glow: { type: Boolean, default: true },
/** The inner scrolls with Core's scroll bar. */
scroll: { type: Boolean, default: false },
/** The inner is a scroller the right rail keeps in view. */
railScroller: { type: Boolean, default: false },
});
const emit = defineEmits(['update:collapsed']);
</script>
<template>
<component
:is="props.tag"
:id="props.id"
v-class-toggle="{
'ui-panel--collapsed': props.collapsible && props.collapsed,
}"
class="ui-panel panel-collapsible"
>
<div v-if="props.glow" class="panel-glow"></div>
<div
:class="[`${props.id}-inner`, { 'ui-scroll': props.scroll }]"
:data-rail-scroller="props.railScroller ? '' : undefined"
>
<div class="ui-panel__head panel-header">
<slot name="title">
<span class="ui-panel__title panel-title" :title="props.hint">{{
props.title
}}</span>
</slot>
<slot name="badge" />
<span class="panel-divider"></span>
<slot name="actions" />
<UiPanelToggle
v-if="props.collapsible"
:target="props.id"
:name="props.title"
:collapsed="props.collapsed"
:sideways="props.sideways"
:words="props.toggleWords"
@update:collapsed="emit('update:collapsed', $event)"
/>
</div>
<slot />
</div>
</component>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { msg, t } from '../../i18n/index.js';
import UiButton from './UiButton.vue';
// A panel's collapse button, internal to the kit: UiPanel draws it, and so
// does PanelCollapseButton for the panels not built on UiPanel. Its glyph and
// words follow `collapsed`; a click asks for the other state.
const props = defineProps({
/** The panel's id, which PanelChrome finds the button by. */
target: { type: String, required: true },
/** The panel's name in "Expand {name}", already translated. */
name: { type: String, default: () => t('panel') },
collapsed: { type: Boolean, required: true },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
sideways: { type: Boolean, default: false },
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
words: { type: Object, default: null },
});
const emit = defineEmits(['update:collapsed']);
const glyph = computed(() => {
if (props.sideways) return props.collapsed ? '◀' : '▶';
return props.collapsed ? '+' : '−';
});
const label = computed(() => {
if (props.words)
return props.collapsed
? props.words.expandLabel
: props.words.collapseLabel;
const key = props.collapsed ? msg('Expand {name}') : msg('Collapse {name}');
return t(key, { name: props.name });
});
const title = computed(() => {
if (props.words)
return props.collapsed
? props.words.expandTitle
: props.words.collapseTitle;
return label.value;
});
</script>
<template>
<UiButton
variant="icon"
class="panel-collapse-btn"
:data-collapse-target="target"
:label="label"
:title="title"
:aria-expanded="String(!collapsed)"
@click="emit('update:collapsed', !collapsed)"
>{{ glyph }}</UiButton
>
</template>
+149 -17
View File
@@ -11,6 +11,8 @@ import { PanelChrome } from './panelChrome.js';
import { followPanelState, vPanelState } from './rail/panelState.js';
import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue';
import RailPanel from './rail/RailPanel.vue';
import UiPanel from './kit/UiPanel.vue';
import { usePanelChromeStore } from './stores/panelChrome.js';
import CommandDockView from './views/CommandDockView.vue';
@@ -57,38 +59,33 @@ test('the store republishes a panel only when its state changes', () => {
);
});
test('a collapse button keeps its markup until presented, then follows the panel', async (t) => {
test('a collapse button follows its markup until presented, then the panel', async (t) => {
const store = mount(t, {
render: () => [
h(PanelCollapseButton, {
target: 'data-panel',
name: 'DATA LAYERS',
initial: '+',
startCollapsed: true,
}),
h(PanelCollapseButton, {
target: 'cctv-panel',
name: 'CCTV',
sideways: true,
initial: '+',
startCollapsed: true,
}),
],
});
const [data, cctv] = document.querySelectorAll('.panel-collapse-btn');
assert.equal(data.textContent, '+');
assert.equal(data.title, 'Collapse panel');
assert.equal(data.hasAttribute('aria-expanded'), false);
assert.equal(data.hasAttribute('aria-label'), false);
assert.equal(data.getAttribute('aria-expanded'), 'false');
assert.equal(data.getAttribute('aria-label'), 'Expand DATA LAYERS');
assert.equal(data.dataset.collapseTarget, 'data-panel');
assert.equal(cctv.textContent, '◀');
const toggled = [];
const token = store.connect({ toggle: (id) => toggled.push(id) });
store.publish(token, 'data-panel', { collapsed: true });
store.publish(token, 'cctv-panel', { collapsed: false });
await nextTick();
assert.equal(data.textContent, '+');
assert.equal(data.getAttribute('aria-expanded'), 'false');
assert.equal(data.getAttribute('aria-label'), 'Expand DATA LAYERS');
assert.equal(data.title, 'Expand DATA LAYERS');
assert.equal(cctv.textContent, '▶');
assert.equal(cctv.getAttribute('aria-expanded'), 'true');
assert.equal(cctv.title, 'Collapse CCTV');
@@ -101,9 +98,144 @@ test('a collapse button keeps its markup until presented, then follows the panel
assert.deepEqual(toggled, ['data-panel']);
});
test("UiPanel draws Core's panel markup and asks for the other state", async (t) => {
const asked = [];
let collapsed = true;
mount(t, {
render: () =>
h(
UiPanel,
{
id: 'demo-panel',
title: 'DEMO',
hint: 'A demo',
collapsible: true,
collapsed,
scroll: true,
railScroller: true,
'onUpdate:collapsed': (next) => asked.push(next),
},
{
badge: () => h('span', { id: 'demo-badge' }),
actions: () => h('button', { id: 'demo-action' }),
default: () => h('div', { id: 'demo-body' }),
},
),
});
const panel = document.getElementById('demo-panel');
assert.equal(
panel.className,
'ui-panel panel-collapsible ui-panel--collapsed',
);
assert.equal(panel.children[0].className, 'panel-glow');
const inner = panel.children[1];
assert.equal(inner.className, 'demo-panel-inner ui-scroll');
assert.equal(inner.hasAttribute('data-rail-scroller'), true);
const [head, body] = inner.children;
assert.deepEqual(
[...head.children].map(
(el) => el.id || el.classList[el.classList.length - 1],
),
[
'panel-title',
'demo-badge',
'panel-divider',
'demo-action',
'panel-collapse-btn',
],
);
assert.equal(head.querySelector('.panel-title').title, 'A demo');
assert.equal(body.id, 'demo-body');
const toggle = head.querySelector('.panel-collapse-btn');
assert.equal(toggle.getAttribute('aria-label'), 'Expand DEMO');
assert.equal(toggle.dataset.collapseTarget, 'demo-panel');
toggle.click();
assert.deepEqual(asked, [false]);
});
test('UiPanel without collapsible has no button, and glow is optional', (t) => {
mount(t, {
render: () =>
h(UiPanel, {
id: 'plain-panel',
title: 'PLAIN',
tag: 'section',
glow: false,
}),
});
const panel = document.getElementById('plain-panel');
assert.equal(panel.tagName, 'SECTION');
assert.equal(panel.querySelector('.panel-glow'), null);
assert.equal(panel.querySelector('.panel-collapse-btn'), null);
});
test('RailPanel follows the store, forwards clicks, and never restores a class its owner removed', async (t) => {
const store = mount(t, {
render: () =>
h(
RailPanel,
{
id: 'radio-panel',
startCollapsed: true,
title: 'RADIO',
class: 'extra',
hidden: '',
},
{ default: () => h('p', { id: 'radio-body' }) },
),
});
const panel = document.getElementById('radio-panel');
assert.equal(
panel.className,
'ui-panel panel-collapsible extra collapsed ui-panel--collapsed',
);
assert.equal(panel.dataset.panelId, 'radio-panel');
assert.equal(panel.hidden, true);
assert.ok(document.getElementById('radio-body'));
const toggle = panel.querySelector('.panel-collapse-btn');
assert.equal(toggle.textContent, '+');
const toggled = [];
const token = store.connect({ toggle: (id) => toggled.push(id) });
store.publish(token, 'radio-panel', { collapsed: false });
assert.equal(panel.classList.contains('collapsed'), false, 'written at once');
await nextTick();
assert.equal(toggle.textContent, '−');
// The re-render above left the class as written; pinning keeps it too.
store.publish(token, 'radio-panel', { pinned: true });
await nextTick();
assert.equal(panel.classList.contains('collapsed'), false);
assert.equal(panel.classList.contains('dock-pinned'), true);
toggle.click();
assert.deepEqual(toggled, ['radio-panel']);
});
test('a panel whose class binding changes keeps the presented state', async (t) => {
const { ref } = await import('vue');
const extra = ref('one');
const store = mount(t, {
render: () =>
h(RailPanel, {
id: 'cctv-panel',
startCollapsed: true,
title: 'CCTV',
class: extra.value,
}),
});
const panel = document.getElementById('cctv-panel');
store.publish(store.connect({}), 'cctv-panel', {
collapsed: false,
pinned: true,
});
extra.value = 'two';
await nextTick();
assert.equal(panel.classList.contains('two'), true);
assert.equal(panel.classList.contains('collapsed'), false);
assert.equal(panel.classList.contains('dock-pinned'), true);
});
test("Radio's collapse button keeps its own wording", async (t) => {
const words = {
initialLabel: 'Expand Radio section',
expandLabel: 'Expand Radio section',
collapseLabel: 'Collapse Radio section',
expandTitle: 'Expand Radio',
@@ -111,11 +243,11 @@ test("Radio's collapse button keeps its own wording", async (t) => {
};
const store = mount(t, {
render: () =>
h(PanelCollapseButton, {
target: 'radio-panel',
initial: '+',
initialTitle: 'Expand Radio',
words,
h(RailPanel, {
id: 'radio-panel',
startCollapsed: true,
title: 'RADIO',
toggleWords: words,
}),
});
const button = document.querySelector('.panel-collapse-btn');
+17 -49
View File
@@ -1,66 +1,34 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { msg, t } from '../../i18n/index.js';
import UiButton from '../kit/UiButton.vue';
import UiPanelToggle from '../kit/UiPanelToggle.vue';
import { usePanelChromeStore } from '../stores/panelChrome.js';
// A panel's collapse button: its glyph, aria-expanded and words follow the
// panel's state in the panel chrome store, and a click asks PanelChrome to
// toggle it. Until PanelChrome presents the panel it shows its markup's glyph
// and tip.
// The collapse button of a panel not built on UiPanel: it follows the panel's
// state in the panel chrome store, and a click asks PanelChrome to toggle it.
// Until PanelChrome presents the panel it follows the markup's state.
const props = defineProps({
target: { type: String, required: true },
/** The panel's name in "Expand {name}", already translated. */
name: { type: String, default: () => t('panel') },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
name: { type: String, default: undefined },
sideways: { type: Boolean, default: false },
/** The glyph and tip before the panel is presented. */
initial: { type: String, required: true },
initialTitle: { type: String, default: () => t('Collapse panel') },
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
words: { type: Object, default: null },
/** Whether the panel's markup starts collapsed. */
startCollapsed: { type: Boolean, default: false },
});
const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome);
const state = computed(() => panels.value[props.target]);
const glyph = computed(() => {
if (!state.value) return props.initial;
if (props.sideways) return state.value.collapsed ? '◀' : '▶';
return state.value.collapsed ? '+' : '−';
});
const label = computed(() => {
if (!state.value) return props.words?.initialLabel;
if (props.words)
return state.value.collapsed
? props.words.expandLabel
: props.words.collapseLabel;
const key = state.value.collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
return t(key, { name: props.name });
});
const title = computed(() => {
if (!state.value) return props.initialTitle;
if (props.words)
return state.value.collapsed
? props.words.expandTitle
: props.words.collapseTitle;
return label.value;
});
const collapsedNow = computed(
() => panels.value[props.target]?.collapsed ?? props.startCollapsed,
);
</script>
<template>
<UiButton
variant="icon"
class="panel-collapse-btn"
:data-collapse-target="target"
:label="label"
:title="title"
:aria-expanded="state ? String(!state.collapsed) : undefined"
@click="chrome.act('toggle', target)"
>{{ glyph }}</UiButton
>
<UiPanelToggle
:target="target"
:name="name"
:sideways="sideways"
:collapsed="collapsedNow"
@update:collapsed="chrome.act('toggle', target)"
/>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import UiPanel from '../kit/UiPanel.vue';
import { usePanelChromeStore } from '../stores/panelChrome.js';
import { vPanelState } from './panelState.js';
// A panel PanelChrome owns: a collapsible UiPanel whose state classes
// (vPanelState) and collapse button follow the panel chrome store, with the
// button's clicks forwarded to PanelChrome. Every other prop, attribute and
// slot passes through to UiPanel.
defineOptions({ inheritAttrs: false });
const props = defineProps({
id: { type: String, required: true },
/** Whether the markup starts collapsed, until PanelChrome presents it. */
startCollapsed: { type: Boolean, default: false },
});
const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome);
const collapsedNow = computed(
() => panels.value[props.id]?.collapsed ?? props.startCollapsed,
);
</script>
<template>
<UiPanel
v-panel-state="props.id"
v-bind="$attrs"
:id="props.id"
:class="{ collapsed: props.startCollapsed }"
:data-panel-id="props.id"
collapsible
:collapsed="collapsedNow"
@update:collapsed="chrome.act('toggle', props.id)"
>
<template v-for="(_, name) in $slots" #[name]
><slot :name="name"
/></template>
</UiPanel>
</template>
+7
View File
@@ -42,8 +42,15 @@ export function followPanelState(store, panelId, el) {
export const vPanelState = {
mounted(el, binding) {
const store = usePanelChromeStore(binding.instance.$pinia);
el._panelStateStore = store;
el._stopPanelState = followPanelState(store, binding.value, el);
},
// A re-render that rewrites the root's class (its class binding changed)
// would restore the markup's state; draw the presented state again.
updated(el, binding) {
const state = el._panelStateStore?.panels[binding.value];
if (state) applyPanelState(el, state);
},
beforeUnmount(el) {
el._stopPanelState?.();
},
+11 -36
View File
@@ -2,8 +2,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import RailPanel from '../rail/RailPanel.vue';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js';
@@ -29,38 +28,15 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<template>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail" class="ui-scroll" v-class-toggle="{ 'bhote-event-active': !!bhotePanel }">
<div id="weather-panel" v-panel-state="'weather-panel'" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="weather-panel" :name="t('WEATHER')" sideways initial="▼" />
</div>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="recent-imagery-panel" v-panel-state="'recent-imagery-panel'" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div class="panel-glow"></div>
<div class="recent-imagery-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
<span class="panel-divider"></span>
<PanelCollapseButton target="recent-imagery-panel" :name="t('RECENT IMAGERY')" sideways initial="▼" />
</div>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="global-context-panel" v-panel-state="'global-context-panel'" class="panel-collapsible collapsed" v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="global-context-panel" :name="t('CONTEXT')" sideways initial="▶" :initial-title="t('Expand panel')" />
</div>
<RailPanel id="weather-panel" hidden :title="t('WEATHER')" sideways rail-scroller>
<template #badge><span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span></template>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</RailPanel>
<RailPanel id="recent-imagery-panel" hidden :title="t('RECENT IMAGERY')" sideways rail-scroller>
<template #badge><span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span></template>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</RailPanel>
<RailPanel id="global-context-panel" start-collapsed v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" :title="t('CONTEXT')" sideways scroll rail-scroller>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
<button id="global-context-flights-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.flightsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.flightsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
@@ -95,8 +71,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
</section>
<RadioPanel />
</div>
</div>
</div>
</RailPanel>
<BhoteKoshiPanel />
</aside>
+6 -23
View File
@@ -1,8 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import RailPanel from '../rail/RailPanel.vue';
import { useSceneSharingStore } from '../stores/sceneSharing.js';
import { useSceneStore } from '../stores/scenes.js';
import { vClassToggle } from '../rail/classToggle.js';
@@ -38,17 +37,9 @@ async function importChosen(event) {
<template>
<div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" v-panel-state="'data-panel'" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n>DATA LAYERS</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="data-panel" :name="t('DATA LAYERS')" initial="+" />
</div>
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</div>
</div>
<RailPanel id="data-panel" start-collapsed class="active" :title="t('DATA LAYERS')">
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</RailPanel>
<!-- CCTV Coverage Panel -->
<CctvPanel />
@@ -57,14 +48,7 @@ async function importChosen(event) {
<StreetLevelPanel />
<!-- Scene Director Panel -->
<div id="scene-panel" v-panel-state="'scene-panel'" v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner ui-scroll">
<div class="panel-header">
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="scene-panel" :name="t('AUTHORING')" initial="+" />
</div>
<RailPanel id="scene-panel" start-collapsed v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" :title="t('AUTHORING')" :hint="t('Create and edit scenes')" scroll>
<p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls">
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label :disabled="off('select')" @change="scenes.act('selectScene', $event.currentTarget.value)"><SceneOptions /></select>
@@ -99,8 +83,7 @@ async function importChosen(event) {
<div id="scene-progress-fill" :style="scene ? { width: `${scene.progress}%` } : undefined">{{ scene?.progress ?? 0 }}%</div>
</div>
<div id="scene-status" role="status" aria-live="polite">{{ scene?.status ?? t('Ready') }}</div>
</div>
</div>
</RailPanel>
</div>
<Teleport to="body">
+3 -12
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailPanel from '../../rail/RailPanel.vue';
import CctvCalValue from '../../cctv/CctvCalValue.vue';
import CctvPicker from '../../cctv/CctvPicker.vue';
import { useCctvStore } from '../../stores/cctv.js';
@@ -28,14 +27,7 @@ const frameData = computed(() =>
</script>
<template>
<div id="cctv-panel" v-panel-state="'cctv-panel'" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="cctv-panel" :name="t('CCTV')" sideways initial="+" />
</div>
<RailPanel id="cctv-panel" start-collapsed :title="t('CCTV')" sideways scroll rail-scroller>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div id="cctv-frame-wrap" :class="['cctv-frame-wrap', { loading: frame?.wrapLoading, 'has-frame': frame?.hasFrame }]">
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
@@ -84,6 +76,5 @@ const frameData = computed(() =>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div id="cctv-summary">{{ summary ?? t('Enable CCTV to start camera-linked intelligence summaries.') }}</div>
</div>
</div>
</div>
</RailPanel>
</template>
+2 -2
View File
@@ -48,7 +48,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways initial="◀" />
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways start-collapsed />
</div>
<div class="pp-panel-body ui-scroll" data-rail-scroller>
<div class="pp-toggle-group">
@@ -128,7 +128,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span>
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" initial="−" />
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" />
</div>
<div id="param-sliders"></div>
</div>
+4 -13
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailPanel from '../../rail/RailPanel.vue';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { vRangeValue } from '../../rail/rangeValue.js';
@@ -21,7 +20,6 @@ const { view: radio, sdr } = storeToRefs(useRadioStore());
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
// The collapse button keeps Radio's own wording, not "Expand RADIO".
const radioWords = {
initialLabel: t('Expand Radio section'),
expandLabel: t('Expand Radio section'),
collapseLabel: t('Collapse Radio section'),
expandTitle: t('Expand Radio'),
@@ -30,14 +28,8 @@ const radioWords = {
</script>
<template>
<section id="radio-panel" v-panel-state="'radio-panel'" class="panel-collapsible collapsed" v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
<div class="radio-panel-inner ui-scroll">
<div class="panel-header">
<span class="panel-title" data-i18n>RADIO</span>
<span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="radio-panel" initial="+" :initial-title="t('Expand Radio')" :words="radioWords" />
</div>
<RailPanel id="radio-panel" tag="section" start-collapsed v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" aria-label="Internet radio and local SDR" data-i18n-aria-label :title="t('RADIO')" :toggle-words="radioWords" :glow="false" scroll>
<template #badge><span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span></template>
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" :class="{ active: radio?.enable.active }" type="button" :aria-pressed="String(radio?.enable.active ?? false)" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
@@ -69,6 +61,5 @@ const radioWords = {
</div>
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<SdrCard />
</div>
</section>
</RailPanel>
</template>
+4 -13
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailPanel from '../../rail/RailPanel.vue';
import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -102,15 +101,8 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</script>
<template>
<div id="street-level-panel" v-panel-state="'street-level-panel'" class="panel-collapsible collapsed" data-panel-id="street-level-panel" :data-sl-enabled="view ? String(view.enabled) : undefined">
<div class="panel-glow"></div>
<div class="street-level-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span>
<button id="sl-status" :class="`sl-status${status.tone ? ` is-${status.tone}` : ''}`" type="button" :aria-pressed="String(status.pressed)" :title="status.title">{{ status.text }}</button>
<PanelCollapseButton target="street-level-panel" :name="t('STREET LEVEL')" sideways initial="◀" :initial-title="t('Expand panel')" />
</div>
<RailPanel id="street-level-panel" start-collapsed :data-sl-enabled="view ? String(view.enabled) : undefined" :title="t('STREET LEVEL')" sideways scroll rail-scroller>
<template #actions><button id="sl-status" :class="`sl-status${status.tone ? ` is-${status.tone}` : ''}`" type="button" :aria-pressed="String(status.pressed)" :title="status.title">{{ status.text }}</button></template>
<div id="sl-body" class="sl-body">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
<div class="sl-viewer-bar">
@@ -168,6 +160,5 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" :hidden="!view?.nearest.visible" :title="nearest.title || undefined" :disabled="nearest.disabled" data-i18n-runtime>{{ nearest.label }}</button>
</div>
</div>
</div>
</div>
</RailPanel>
</template>