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

+8
View File
@@ -8,6 +8,14 @@
the bottom tab bar. The rail is the first view built with Vue and Pinia, the bottom tab bar. The rail is the first view built with Vue and Pinia,
which now ship with the app; see docs/VUE-MIGRATION.md (samehkhamis). which now ship with the app; see docs/VUE-MIGRATION.md (samehkhamis).
- The Data, Authoring, Weather, Recent Imagery, Context, CCTV, Street Level
and Radio panels are one component, `UiPanel` (`src/ui/kit/`), with the
same markup plus the kit's `ui-panel` classes, their titles translated by
`t()` rather than `data-i18n`; `RailPanel` binds it to the panel chrome
store. `PanelCollapseButton` drops its `initial` and `initialTitle` props
and takes `startCollapsed`, so before startup presents a panel its button
already shows that state's glyph, words and `aria-expanded` (samehkhamis).
- The page's panels, dock, cockpit and dialogs are Vue views with the same - The page's panels, dock, cockpit and dialogs are Vue views with the same
markup as before. Hosts that do not initialize Core's locale pass markup as before. Hosts that do not initialize Core's locale pass
`localePicker: false` to `createApplicationControls` to drop the language `localePicker: false` to `createApplicationControls` to drop the language
+15 -6
View File
@@ -36,14 +36,20 @@ one-panel rule too. Share links continue to record which panels are open.
## Component kit ## Component kit
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon` `src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`,
and `UiButton` so far, with more (`UiPanel`, `UiSegmented`, `UiSelect` and `UiButton` and `UiPanel` so far, with more (`UiSegmented`, `UiSelect` and
so on) joining as the controls they serve are converted. Names, props, so on) joining as the controls they serve are converted. One component
serves every use of a control, with props for the differences (`UiPanel`'s
`sideways`, `glow`, `scroll`), rather than a copy per panel. Names, props,
events and class names follow Halfpixel's `ui-vue` kit so the two can merge events and class names follow Halfpixel's `ui-vue` kit so the two can merge
later, but the components are written for this repository and styled by its later, but the components are written for this repository and styled by its
existing stylesheet and custom properties; a kit class such as existing stylesheet and custom properties; a kit class such as
`ui-button--icon` carries no Core style yet, and a converted control keeps `ui-button--icon` carries no Core style yet, and a converted control keeps
its own classes beside it. They contain no application state. its own classes beside it. They contain no application state.
`UiPanel` takes ui-vue's `title`, slots and `v-model:collapsed`, but its
`collapsed` drives only the button and `ui-panel--collapsed`: the panel's
owner hides the body by writing the root's `collapsed` class at once, and
there is no `closable`, since Core's panels collapse rather than close.
Controls share one skin, so every panel reads the same. Every scrolling Controls share one skin, so every panel reads the same. Every scrolling
surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
@@ -64,8 +70,10 @@ a feature supplying only its accent.
when a view needs one, mirroring state an owner already publishes, so when a view needs one, mirroring state an owner already publishes, so
there is never a second owner of the same state. there is never a second owner of the same state.
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store 2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
mirrors each side's panels and the rail renders from it. Each panel's mirrors each side's panels and the rail renders from it. The rail panels
collapse button (`PanelCollapseButton`), the dock tray toggles and the are `RailPanel`s: the kit's `UiPanel` drawing Core's panel markup, bound to the `gev-panel-chrome`
store, with their titles rendered by `t()` rather than `data-i18n`. Each panel's
collapse button, the dock tray toggles and the
dock pins render the open and pinned state PanelChrome presents to the dock pins render the open and pinned state PanelChrome presents to the
`gev-panel-chrome` store, and their clicks call it there. Each panel's `gev-panel-chrome` store, and their clicks call it there. Each panel's
root draws its own state from there too (`v-panel-state`: `collapsed`, root draws its own state from there too (`v-panel-state`: `collapsed`,
@@ -75,7 +83,8 @@ a feature supplying only its accent.
watcher, not on Vue's next render, because the rails measure a panel in watcher, not on Vue's next render, because the rails measure a panel in
the same task that opens or closes it. Done. the same task that opens or closes it. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids, 3. **Panels.** Each template and its wiring become a component, keeping ids,
classes, `data-i18n` text and CSS. The page places each view with a classes and CSS; text a template renders with `t()` drops its `data-i18n`
marker. The page places each view with a
`.gev-view` host (`display: contents`), and `mountApplicationViews()` `.gev-view` host (`display: contents`), and `mountApplicationViews()`
(`src/ui/views/index.js`) renders the view into it before the page is (`src/ui/views/index.js`) renders the view into it before the page is
translated and wired. `createApplication()` calls it too, removing the translated and wired. `createApplication()` calls it too, removing the
+9 -5
View File
@@ -25,7 +25,6 @@ surface. Keep the header and any outward popup outside the scrolling body:
class="panel-surface-control" class="panel-surface-control"
target="example-panel" target="example-panel"
:name="t('Example')" :name="t('Example')"
initial="−"
/> />
</header> </header>
<div data-panel-body data-rail-scroller> <div data-panel-body data-rail-scroller>
@@ -90,10 +89,15 @@ Do not copy the rail example's `.panel-collapse-btn` class into the dock:
existing dock rules intentionally hide that class. The generic surface skin existing dock rules intentionally hide that class. The generic surface skin
does not replace the dock's disclosure policy. does not replace the dock's disclosure policy.
`PanelCollapseButton` (`src/ui/rail/`) renders a panel's collapse button A panel with Core's standard header (glow, title, badges, divider, actions
(`.panel-collapse-btn`) from the open state `PanelChrome` presents to the and collapse button) is a `RailPanel` (`src/ui/rail/`): the kit's `UiPanel`
`gev-panel-chrome` store, and its click asks `PanelChrome` to toggle the bound to the `gev-panel-chrome` store, with its state classes drawn by
panel through `setPanelCollapsed`. `PanelChrome` collects the buttons' `v-panel-state` and its button's click asking `PanelChrome` to toggle it.
A panel with its own header uses `PanelCollapseButton`, which renders the
collapse button (`.panel-collapse-btn`) from the open state `PanelChrome`
presents to that store; until then, both show their `startCollapsed` state,
which should match the markup's `collapsed` class.
Either way, the click reaches `PanelChrome`'s `setPanelCollapsed`. `PanelChrome` collects the buttons'
targets at initialization and binds each panel's Escape. For additions targets at initialization and binds each panel's Escape. For additions
present at startup, keep those hooks and the existing chrome lifecycle. A present at startup, keep those hooks and the existing chrome lifecycle. A
dynamically mounted panel must own its disclosure: its own state, button and dynamically mounted panel must own its disclosure: its own state, button and
+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', () => { 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[ "]/); const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
assert.ok(panel, 'Global Context panel is missing'); 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'); 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]) => 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']); 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...", "Search any location...": "Ort suchen...",
"WEATHER": "WETTER", "WEATHER": "WETTER",
"CONTEXT": "KONTEXT", "CONTEXT": "KONTEXT",
"Expand panel": "Bereich ausklappen",
"Context mode": "Kontextmodus", "Context mode": "Kontextmodus",
"CONTACTS": "KONTAKTE", "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.", "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", "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", "Enable cockpit weather effects": "Activar los efectos climáticos de la cabina",
"CONTEXT": "CONTEXTO", "CONTEXT": "CONTEXTO",
"Expand panel": "Expandir panel",
"Context mode": "Modo de contexto", "Context mode": "Modo de contexto",
"CONTACTS": "CONTACTOS", "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.", "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", "RECENT IMAGERY": "IMAGERIE RÉCENTE",
"Imagery days in the box": "Jours d'imagerie dans la zone", "Imagery days in the box": "Jours d'imagerie dans la zone",
"CONTEXT": "CONTEXTE", "CONTEXT": "CONTEXTE",
"Expand panel": "Développer le panneau",
"Context mode": "Mode de contexte", "Context mode": "Mode de contexte",
"CONTACTS": "CONTACTS", "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.", "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", "RECENT IMAGERY": "IMMAGINI RECENTI",
"Imagery days in the box": "Giorni con immagini nel riquadro", "Imagery days in the box": "Giorni con immagini nel riquadro",
"CONTEXT": "CONTESTO", "CONTEXT": "CONTESTO",
"Expand panel": "Espandi il pannello",
"Context mode": "Modalità contesto", "Context mode": "Modalità contesto",
"CONTACTS": "CONTATTI", "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.", "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": "最近の衛星画像", "RECENT IMAGERY": "最近の衛星画像",
"Imagery days in the box": "範囲内の画像のある日", "Imagery days in the box": "範囲内の画像のある日",
"CONTEXT": "コンテキスト", "CONTEXT": "コンテキスト",
"Expand panel": "パネルを展開",
"Context mode": "コンテキストモード", "Context mode": "コンテキストモード",
"CONTACTS": "コンタクト", "CONTACTS": "コンタクト",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "選択した種類(航空機、船舶、施設)の最寄りのコンタクトを順に切り替えます。衛星は独立して追跡されます。", "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", "RECENT IMAGERY": "IMAGENS RECENTES",
"Imagery days in the box": "Dias com imagens na área", "Imagery days in the box": "Dias com imagens na área",
"CONTEXT": "CONTEXTO", "CONTEXT": "CONTEXTO",
"Expand panel": "Expandir painel",
"Context mode": "Modo de contexto", "Context mode": "Modo de contexto",
"CONTACTS": "CONTATOS", "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.", "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", "FLIR": "FLIR",
"Style": "Стиль", "Style": "Стиль",
"STATION SITE": "САЙТ СТАНЦИИ", "STATION SITE": "САЙТ СТАНЦИИ",
"Expand panel": "Развернуть панель",
"CONTACTS": "КОНТАКТЫ", "CONTACTS": "КОНТАКТЫ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.", "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.",
"SEARCH NEARBY SITES": "ПОИСК ОБЪЕКТОВ РЯДОМ", "SEARCH NEARBY SITES": "ПОИСК ОБЪЕКТОВ РЯДОМ",
-1
View File
@@ -2861,7 +2861,6 @@
"RECENT IMAGERY": "СВІЖІ ЗНІМКИ", "RECENT IMAGERY": "СВІЖІ ЗНІМКИ",
"Imagery days in the box": "Дні зі знімками в області", "Imagery days in the box": "Дні зі знімками в області",
"CONTEXT": "КОНТЕКСТ", "CONTEXT": "КОНТЕКСТ",
"Expand panel": "Розгорнути панель",
"Context mode": "Режим контексту", "Context mode": "Режим контексту",
"CONTACTS": "КОНТАКТИ", "CONTACTS": "КОНТАКТИ",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Гортає найближчі контакти вибраного вами типу — літаки, судна, обʼєкти. Супутники відстежуються окремо.", "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Гортає найближчі контакти вибраного вами типу — літаки, судна, обʼєкти. Супутники відстежуються окремо.",
-1
View File
@@ -2545,7 +2545,6 @@
"RECENT IMAGERY": "近期影像", "RECENT IMAGERY": "近期影像",
"Imagery days in the box": "视野内的影像天数", "Imagery days in the box": "视野内的影像天数",
"CONTEXT": "态势", "CONTEXT": "态势",
"Expand panel": "展开面板",
"Context mode": "态势模式", "Context mode": "态势模式",
"CONTACTS": "目标", "CONTACTS": "目标",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。", "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。",
-1
View File
@@ -2533,7 +2533,6 @@
"RECENT IMAGERY": "近期影像", "RECENT IMAGERY": "近期影像",
"Imagery days in the box": "視野內的影像天數", "Imagery days in the box": "視野內的影像天數",
"CONTEXT": "態勢", "CONTEXT": "態勢",
"Expand panel": "展開面板",
"Context mode": "態勢模式", "Context mode": "態勢模式",
"CONTACTS": "目標", "CONTACTS": "目標",
"Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循環切換你所選類型中最近的目標,包括飛機、船舶、設施。衛星獨立跟蹤。", "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', () => { test('Street Level is a collapsible right-rail panel that starts collapsed', () => {
assert.match( assert.match(
html, 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( assert.match(
html, html,
@@ -26,7 +26,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
); );
assert.match( assert.match(
html, 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. // Provider-neutral header: no vendor mark; the inner restores its scroll.
assert.doesNotMatch(html, /sl-mark|mly-mark/); 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 (◀ / ▶). // A right-rail panel's collapse button opens sideways (◀ / ▶).
assert.match( assert.match(
read('src/ui/views/panels/StreetLevelPanel.vue'), read('src/ui/views/panels/StreetLevelPanel.vue'),
/<PanelCollapseButton[^>]*target="street-level-panel"[^>]*\bsideways\b/, /<RailPanel id="street-level-panel"[^>]*\bsideways\b/,
); );
assert.match( assert.match(
layoutController, layoutController,
+1 -1
View File
@@ -84,7 +84,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
); );
assert.match( assert.match(
html, html,
/<div id="cctv-panel"[^>]*class="panel-collapsible collapsed"/, /<RailPanel id="cctv-panel"[^>]*\bstart-collapsed\b/,
'the CCTV panel starts collapsed in the markup', '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) => { test('before ContextControls renders, the switch shows its initial markup', (t) => {
const { $ } = fixture(t); const { $ } = fixture(t);
const panel = $('global-context-panel'); 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); assert.equal(panel.hasAttribute('data-context-mode'), false);
for (const id of [ for (const id of [
'global-context-flights-btn', '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'); panel.classList.remove('collapsed');
controls._contextMode = 'flights'; controls._contextMode = 'flights';
await sync(); 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(panel.getAttribute('data-context-mode'), 'flights');
assert.equal($('context-flights-view').hidden, false); assert.equal($('context-flights-view').hidden, false);
panel.classList.add('collapsed'); panel.classList.add('collapsed');
controls._contextMode = null; controls._contextMode = null;
await sync(); 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(panel.getAttribute('data-context-mode'), 'none');
assert.equal($('context-mode-standby').hidden, false); assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true); 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/, /class="cyber-panel-body ui-scroll" data-rail-scroller/,
); );
assert.ok( 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"'), 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 { followPanelState, vPanelState } from './rail/panelState.js';
import { bindPanelDisclosure } from './panelDisclosure.js'; import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue'; 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 { usePanelChromeStore } from './stores/panelChrome.js';
import CommandDockView from './views/CommandDockView.vue'; 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, { const store = mount(t, {
render: () => [ render: () => [
h(PanelCollapseButton, { h(PanelCollapseButton, {
target: 'data-panel', target: 'data-panel',
name: 'DATA LAYERS', name: 'DATA LAYERS',
initial: '+', startCollapsed: true,
}), }),
h(PanelCollapseButton, { h(PanelCollapseButton, {
target: 'cctv-panel', target: 'cctv-panel',
name: 'CCTV', name: 'CCTV',
sideways: true, sideways: true,
initial: '+', startCollapsed: true,
}), }),
], ],
}); });
const [data, cctv] = document.querySelectorAll('.panel-collapse-btn'); const [data, cctv] = document.querySelectorAll('.panel-collapse-btn');
assert.equal(data.textContent, '+'); assert.equal(data.textContent, '+');
assert.equal(data.title, 'Collapse panel'); assert.equal(data.getAttribute('aria-expanded'), 'false');
assert.equal(data.hasAttribute('aria-expanded'), false); assert.equal(data.getAttribute('aria-label'), 'Expand DATA LAYERS');
assert.equal(data.hasAttribute('aria-label'), false);
assert.equal(data.dataset.collapseTarget, 'data-panel'); assert.equal(data.dataset.collapseTarget, 'data-panel');
assert.equal(cctv.textContent, '◀');
const toggled = []; const toggled = [];
const token = store.connect({ toggle: (id) => toggled.push(id) }); const token = store.connect({ toggle: (id) => toggled.push(id) });
store.publish(token, 'data-panel', { collapsed: true });
store.publish(token, 'cctv-panel', { collapsed: false }); store.publish(token, 'cctv-panel', { collapsed: false });
await nextTick(); 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.textContent, '▶');
assert.equal(cctv.getAttribute('aria-expanded'), 'true'); assert.equal(cctv.getAttribute('aria-expanded'), 'true');
assert.equal(cctv.title, 'Collapse CCTV'); 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']); 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) => { test("Radio's collapse button keeps its own wording", async (t) => {
const words = { const words = {
initialLabel: 'Expand Radio section',
expandLabel: 'Expand Radio section', expandLabel: 'Expand Radio section',
collapseLabel: 'Collapse Radio section', collapseLabel: 'Collapse Radio section',
expandTitle: 'Expand Radio', expandTitle: 'Expand Radio',
@@ -111,11 +243,11 @@ test("Radio's collapse button keeps its own wording", async (t) => {
}; };
const store = mount(t, { const store = mount(t, {
render: () => render: () =>
h(PanelCollapseButton, { h(RailPanel, {
target: 'radio-panel', id: 'radio-panel',
initial: '+', startCollapsed: true,
initialTitle: 'Expand Radio', title: 'RADIO',
words, toggleWords: words,
}), }),
}); });
const button = document.querySelector('.panel-collapse-btn'); const button = document.querySelector('.panel-collapse-btn');
+17 -49
View File
@@ -1,66 +1,34 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { msg, t } from '../../i18n/index.js'; import UiPanelToggle from '../kit/UiPanelToggle.vue';
import UiButton from '../kit/UiButton.vue';
import { usePanelChromeStore } from '../stores/panelChrome.js'; import { usePanelChromeStore } from '../stores/panelChrome.js';
// A panel's collapse button: its glyph, aria-expanded and words follow the // The collapse button of a panel not built on UiPanel: it follows the panel's
// panel's state in the panel chrome store, and a click asks PanelChrome to // state in the panel chrome store, and a click asks PanelChrome to toggle it.
// toggle it. Until PanelChrome presents the panel it shows its markup's glyph // Until PanelChrome presents the panel it follows the markup's state.
// and tip.
const props = defineProps({ const props = defineProps({
target: { type: String, required: true }, target: { type: String, required: true },
/** The panel's name in "Expand {name}", already translated. */ /** The panel's name in "Expand {name}", already translated. */
name: { type: String, default: () => t('panel') }, name: { type: String, default: undefined },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
sideways: { type: Boolean, default: false }, sideways: { type: Boolean, default: false },
/** The glyph and tip before the panel is presented. */ /** Whether the panel's markup starts collapsed. */
initial: { type: String, required: true }, startCollapsed: { type: Boolean, default: false },
initialTitle: { type: String, default: () => t('Collapse panel') },
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
words: { type: Object, default: null },
}); });
const chrome = usePanelChromeStore(); const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome); const { panels } = storeToRefs(chrome);
const state = computed(() => panels.value[props.target]); const collapsedNow = computed(
() => panels.value[props.target]?.collapsed ?? props.startCollapsed,
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;
});
</script> </script>
<template> <template>
<UiButton <UiPanelToggle
variant="icon" :target="target"
class="panel-collapse-btn" :name="name"
:data-collapse-target="target" :sideways="sideways"
:label="label" :collapsed="collapsedNow"
:title="title" @update:collapsed="chrome.act('toggle', target)"
:aria-expanded="state ? String(!state.collapsed) : undefined" />
@click="chrome.act('toggle', target)"
>{{ glyph }}</UiButton
>
</template> </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 = { export const vPanelState = {
mounted(el, binding) { mounted(el, binding) {
const store = usePanelChromeStore(binding.instance.$pinia); const store = usePanelChromeStore(binding.instance.$pinia);
el._panelStateStore = store;
el._stopPanelState = followPanelState(store, binding.value, el); 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) { beforeUnmount(el) {
el._stopPanelState?.(); el._stopPanelState?.();
}, },
+11 -36
View File
@@ -2,8 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js'; import RailPanel from '../rail/RailPanel.vue';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js'; import { useCockpitStore } from '../stores/cockpit.js';
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js'; import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js';
@@ -29,38 +28,15 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<template> <template>
<!-- Global Context is deliberately independent from the left layer accordion. --> <!-- 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 }"> <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> <RailPanel id="weather-panel" hidden :title="t('WEATHER')" sideways rail-scroller>
<div class="panel-glow"></div> <template #badge><span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span></template>
<div class="weather-panel-inner" data-rail-scroller> <div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
<div class="panel-header"> </RailPanel>
<span class="panel-title" data-i18n>WEATHER</span> <RailPanel id="recent-imagery-panel" hidden :title="t('RECENT IMAGERY')" sideways rail-scroller>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span> <template #badge><span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span></template>
<span class="panel-divider"></span> <div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
<PanelCollapseButton target="weather-panel" :name="t('WEATHER')" sideways initial="▼" /> </RailPanel>
</div> <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 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>
<div class="cyber-panel-body ui-scroll" data-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> <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> <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> </section>
<RadioPanel /> <RadioPanel />
</div> </div>
</div> </RailPanel>
</div>
<BhoteKoshiPanel /> <BhoteKoshiPanel />
</aside> </aside>
+6 -23
View File
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js'; import RailPanel from '../rail/RailPanel.vue';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { useSceneSharingStore } from '../stores/sceneSharing.js'; import { useSceneSharingStore } from '../stores/sceneSharing.js';
import { useSceneStore } from '../stores/scenes.js'; import { useSceneStore } from '../stores/scenes.js';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
@@ -38,17 +37,9 @@ async function importChosen(event) {
<template> <template>
<div id="left-panel-stack" class="ui-scroll"> <div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel --> <!-- Data Layer Toggle Panel -->
<div id="data-panel" v-panel-state="'data-panel'" class="panel-collapsible collapsed active" data-panel-id="data-panel"> <RailPanel id="data-panel" start-collapsed class="active" :title="t('DATA LAYERS')">
<div class="panel-glow"></div> <div id="data-toggles" class="data-toggle-list ui-scroll"></div>
<div class="data-panel-inner"> </RailPanel>
<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>
<!-- CCTV Coverage Panel --> <!-- CCTV Coverage Panel -->
<CctvPanel /> <CctvPanel />
@@ -57,14 +48,7 @@ async function importChosen(event) {
<StreetLevelPanel /> <StreetLevelPanel />
<!-- Scene Director Panel --> <!-- 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"> <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>
<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>
<p class="scene-step-heading" data-i18n>SCENE</p> <p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls"> <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> <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 id="scene-progress-fill" :style="scene ? { width: `${scene.progress}%` } : undefined">{{ scene?.progress ?? 0 }}%</div>
</div> </div>
<div id="scene-status" role="status" aria-live="polite">{{ scene?.status ?? t('Ready') }}</div> <div id="scene-status" role="status" aria-live="polite">{{ scene?.status ?? t('Ready') }}</div>
</div> </RailPanel>
</div>
</div> </div>
<Teleport to="body"> <Teleport to="body">
+3 -12
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js'; import RailPanel from '../../rail/RailPanel.vue';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import CctvCalValue from '../../cctv/CctvCalValue.vue'; import CctvCalValue from '../../cctv/CctvCalValue.vue';
import CctvPicker from '../../cctv/CctvPicker.vue'; import CctvPicker from '../../cctv/CctvPicker.vue';
import { useCctvStore } from '../../stores/cctv.js'; import { useCctvStore } from '../../stores/cctv.js';
@@ -28,14 +27,7 @@ const frameData = computed(() =>
</script> </script>
<template> <template>
<div id="cctv-panel" v-panel-state="'cctv-panel'" class="panel-collapsible collapsed" data-panel-id="cctv-panel"> <RailPanel id="cctv-panel" start-collapsed :title="t('CCTV')" sideways scroll rail-scroller>
<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>
<div class="cyber-panel-body ui-scroll" data-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 }]"> <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> <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 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 id="cctv-summary">{{ summary ?? t('Enable CCTV to start camera-linked intelligence summaries.') }}</div>
</div> </div>
</div> </RailPanel>
</div>
</template> </template>
+2 -2
View File
@@ -48,7 +48,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="compact pp-header-row"> <div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span> <span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></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>
<div class="pp-panel-body ui-scroll" data-rail-scroller> <div class="pp-panel-body ui-scroll" data-rail-scroller>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
@@ -128,7 +128,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="param-panel-header"> <div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span> <span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></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>
<div id="param-sliders"></div> <div id="param-sliders"></div>
</div> </div>
+4 -13
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js'; import RailPanel from '../../rail/RailPanel.vue';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../../rail/classToggle.js'; import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js'; import { vFieldValue } from '../../rail/fieldValue.js';
import { vRangeValue } from '../../rail/rangeValue.js'; import { vRangeValue } from '../../rail/rangeValue.js';
@@ -21,7 +20,6 @@ const { view: radio, sdr } = storeToRefs(useRadioStore());
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null); const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
// The collapse button keeps Radio's own wording, not "Expand RADIO". // The collapse button keeps Radio's own wording, not "Expand RADIO".
const radioWords = { const radioWords = {
initialLabel: t('Expand Radio section'),
expandLabel: t('Expand Radio section'), expandLabel: t('Expand Radio section'),
collapseLabel: t('Collapse Radio section'), collapseLabel: t('Collapse Radio section'),
expandTitle: t('Expand Radio'), expandTitle: t('Expand Radio'),
@@ -30,14 +28,8 @@ const radioWords = {
</script> </script>
<template> <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> <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>
<div class="radio-panel-inner ui-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="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>
<div class="radio-directory-row"> <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> <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> <label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
@@ -69,6 +61,5 @@ const radioWords = {
</div> </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> <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 /> <SdrCard />
</div> </RailPanel>
</section>
</template> </template>
+4 -13
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue'; import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js'; import RailPanel from '../../rail/RailPanel.vue';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailChips from '../../rail/RailChips.vue'; import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js'; import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -102,15 +101,8 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</script> </script>
<template> <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"> <RailPanel id="street-level-panel" start-collapsed :data-sl-enabled="view ? String(view.enabled) : undefined" :title="t('STREET LEVEL')" sideways scroll rail-scroller>
<div class="panel-glow"></div> <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 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>
<div id="sl-body" class="sl-body"> <div id="sl-body" class="sl-body">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open"> <div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
<div class="sl-viewer-bar"> <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> <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>
</div> </RailPanel>
</div>
</template> </template>