feat(kit): render the status badges and empty states with UiBadge and UiEmpty

UiBadge takes a tone and carries Core's gev-status-badge class, with
the density variants and colours left to its callers; the CCTV source and
calibration badges and the rail card badges render through it. UiEmpty takes
a title and hint, with the title as the line itself, since Core's
empty states are one line; the Scene shot list, mission roster and imagery
strip use it.
This commit is contained in:
Sameh Khamis committed 2026-11-01 12:31:09 -08:00
1 parent ecaf723035
commit b9ecb9b9f5
12 files changed
+126 -19

No files matched your search

+3 -1
View File
@@ -68,7 +68,9 @@
kit's `UiButton`, which gains `pressedClass`; so are the rail card kit's `UiButton`, which gains `pressedClass`; so are the rail card
chips, map source chips and location pills (which now report chips, map source chips and location pills (which now report
`aria-pressed`), and the voice card's chips `aria-pressed`), and the voice card's chips
are the kit's new `UiChip`. The selects are the kit's new `UiSelect`. are the kit's new `UiChip`. The selects are the kit's new `UiSelect`, the
status badges its new `UiBadge` and the one-line empty states its new
`UiEmpty`.
`syncScopeControls` `syncScopeControls`
(`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are (`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are
deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue
+8
View File
@@ -72,6 +72,14 @@ options that carry their own attributes come through the slot, a caller's
Without `hold`, a select takes its model back after its own change renders, Without `hold`, a select takes its model back after its own change renders,
so a pick its owner refuses goes back; an owner that answers later (the SDR so a pick its owner refuses goes back; an owner that answers later (the SDR
gain) takes `hold`. Every select in the views is a `UiSelect`. gain) takes `hold`. Every select in the views is a `UiSelect`.
`UiBadge` takes ui-vue's `tone` and carries Core's `gev-status-badge`; its
density variants and colours stay caller classes, and it renders the CCTV
source and calibration badges and the rail card badges. `UiEmpty` takes
ui-vue's `title` and `hint`, but Core's empty states are one line, so the title
is the text itself rather than a bold heading; the Scene shot list, mission
roster and imagery strip use it. The locale picker's and the mission tables'
empty rows stay a paragraph and table cells, and no Core block matches
`UiSection`'s header and well, so none uses it yet.
`UiSlider` keeps the native range input where ui-vue draws a `UiSlider` keeps the native range input where ui-vue draws a
`role="slider"` track, so Core's keyboard, form behaviour and styling stay; `role="slider"` track, so Core's keyboard, form behaviour and styling stay;
it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a
+3
View File
@@ -1136,6 +1136,9 @@
"src/ui/kit/UiChip.test.mjs", "src/ui/kit/UiChip.test.mjs",
"src/ui/kit/UiSelect.js", "src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs", "src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs", "src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs", "src/testSupport/vueDom.mjs",
+10 -4
View File
@@ -136,7 +136,9 @@
"src/sdr/gain.js", "src/sdr/gain.js",
"src/ui/kit/UiSlider.js", "src/ui/kit/UiSlider.js",
"src/ui/kit/UiChip.vue", "src/ui/kit/UiChip.vue",
"src/ui/kit/UiSelect.js" "src/ui/kit/UiSelect.js",
"src/ui/kit/UiEmpty.vue",
"src/ui/kit/UiBadge.vue"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
}, },
@@ -670,7 +672,8 @@
"src/ui/rail/RailTimeline.vue", "src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/kit/UiButton.vue" "src/ui/kit/UiButton.vue",
"src/ui/kit/UiBadge.vue"
], ],
"external": ["vue"] "external": ["vue"]
}, },
@@ -1070,7 +1073,8 @@
"src/ui/rail/panelState.js", "src/ui/rail/panelState.js",
"src/ui/rail/fieldValue.js", "src/ui/rail/fieldValue.js",
"src/ui/kit/UiSlider.js", "src/ui/kit/UiSlider.js",
"src/ui/kit/UiButton.vue" "src/ui/kit/UiButton.vue",
"src/ui/kit/UiBadge.vue"
], ],
"external": ["cesium", "vue", "pinia"] "external": ["cesium", "vue", "pinia"]
}, },
@@ -3081,7 +3085,9 @@
"src/ui/rail/panelState.js", "src/ui/rail/panelState.js",
"src/ui/rail/fieldValue.js", "src/ui/rail/fieldValue.js",
"src/ui/kit/UiSlider.js", "src/ui/kit/UiSlider.js",
"src/ui/kit/UiButton.vue" "src/ui/kit/UiButton.vue",
"src/ui/kit/UiEmpty.vue",
"src/ui/kit/UiBadge.vue"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+49
View File
@@ -0,0 +1,49 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let UiBadge;
let UiEmpty;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
UiBadge = (await import('./UiBadge.vue')).default;
UiEmpty = (await import('./UiEmpty.vue')).default;
});
after(() => uninstall());
test('a badge carries the kit classes, Core’s and the caller’s', async () => {
const wrapper = mount(UiBadge, {
attrs: { id: 'cal', class: 'gev-status-badge--box', role: 'status' },
slots: { default: 'CAL · --' },
});
const badge = wrapper.get('span');
assert.deepEqual(badge.classes(), [
'ui-badge',
'gev-status-badge',
'ui-badge--neutral',
'gev-status-badge--box',
]);
assert.equal(badge.attributes('role'), 'status');
assert.equal(badge.element.textContent, 'CAL · --');
await wrapper.setProps({ tone: 'warn' });
assert.ok(badge.classes().includes('ui-badge--warn'));
wrapper.unmount();
});
test('an empty state is its title, then its hint', async () => {
const wrapper = mount(UiEmpty, {
props: { title: 'No shots yet.' },
attrs: { class: 'scene-shot-empty', hidden: true },
});
const empty = wrapper.get('div');
assert.deepEqual(empty.classes(), ['ui-empty', 'scene-shot-empty']);
assert.equal(empty.element.textContent, 'No shots yet.');
assert.equal(empty.attributes('title'), undefined);
assert.equal(empty.element.hidden, true);
await wrapper.setProps({ hint: 'Capture one.' });
assert.equal(empty.get('.ui-empty__hint').text(), 'Capture one.');
wrapper.unmount();
});
+16
View File
@@ -0,0 +1,16 @@
<script setup>
// Core's read-only badge, with @halfpixel/ui-vue UiBadge's `tone` and classes
// so the two kits can merge. It carries Core's `gev-status-badge` class, and
// callers add its density variants (`gev-status-badge--box`, `--plain`) and
// the feature classes that colour it; the kit classes carry no Core styles
// yet. Not mirrored yet: pulse, busy, the tone glyphs and the clipped title.
defineProps({
tone: { type: String, default: 'neutral' },
});
</script>
<template>
<span class="ui-badge gev-status-badge" :class="`ui-badge--${tone}`"
><slot
/></span>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup>
// Core's empty state, with @halfpixel/ui-vue UiEmpty's `title` and `hint` so
// the two kits can merge. Core's empty states are one line, so the title is
// the text itself rather than ui-vue's bold heading; a hint follows it. Core
// styles it through the classes its callers pass. Not mirrored yet: icon.
defineProps({
title: { type: String, required: true },
hint: { type: String, default: undefined },
});
</script>
<template>
<div class="ui-empty">
{{ title }}<span v-if="hint" class="ui-empty__hint">{{ hint }}</span>
</div>
</template>
+4 -3
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount } from 'vue'; import { computed, onBeforeUnmount } from 'vue';
import RailCardBlocks from './RailCardBlocks.vue'; import RailCardBlocks from './RailCardBlocks.vue';
import UiBadge from '../kit/UiBadge.vue';
// One keyed card: a disclosure header and its blocks. The owner decides // One keyed card: a disclosure header and its blocks. The owner decides
// which card is open. // which card is open.
@@ -45,10 +46,10 @@ const nextBodyId = () => ++bodies;
><span class="rail-card-title rail-card-nowrap" :title="card.title">{{ ><span class="rail-card-title rail-card-nowrap" :title="card.title">{{
card.title card.title
}}</span></span }}</span></span
><span ><UiBadge
:class="`gev-status-badge gev-status-badge--plain rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`" :class="`gev-status-badge--plain rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`"
:hidden="!card.badge" :hidden="!card.badge"
>{{ card.badge || '' }}</span >{{ card.badge || '' }}</UiBadge
></span ></span
><span class="rail-card-disclosure" aria-hidden="true">{{ ><span class="rail-card-disclosure" aria-hidden="true">{{
open ? '▾' : '▸' open ? '▾' : '▸'
@@ -6,6 +6,7 @@ import RailCardBlocks from '../rail/RailCardBlocks.vue';
import RailCards from '../rail/RailCards.vue'; import RailCards from '../rail/RailCards.vue';
import { vRangeValue } from '../rail/rangeValue.js'; import { vRangeValue } from '../rail/rangeValue.js';
import RecentImageryCard from './RecentImageryCard.vue'; import RecentImageryCard from './RecentImageryCard.vue';
import UiEmpty from '../kit/UiEmpty.vue';
// Every block exists from the start, in its final order. The owner computes // Every block exists from the start, in its final order. The owner computes
// the view and listens on the root; a mode choice calls `onSetMode`. // the view and listens on the root; a mode choice calls `onSetMode`.
@@ -78,10 +79,10 @@ const modeOptions = computed(() =>
><RecentImageryCard ><RecentImageryCard
v-for="card in view.strip.cards" v-for="card in view.strip.cards"
:key="card.key" :key="card.key"
:card="card" :card="card" /><UiEmpty
/><div class="ri-strip-empty" :hidden="view.strip.cards.length > 0">{{ class="ri-strip-empty"
view.strip.empty :hidden="view.strip.cards.length > 0"
}}</div></div :title="view.strip.empty" /></div
><div id="ri-hint" class="ri-block ri-line ri-hint" :title="view.hint">{{ ><div id="ri-hint" class="ri-block ri-line ri-hint" :title="view.hint">{{
view.hint view.hint
}}</div }}</div
+6 -3
View File
@@ -5,6 +5,7 @@ import { t } from '../../i18n/index.js';
import { describeShot } from '../scenePresentation.js'; import { describeShot } from '../scenePresentation.js';
import UiButton from '../kit/UiButton.vue'; import UiButton from '../kit/UiButton.vue';
import { useSceneStore } from '../stores/scenes.js'; import { useSceneStore } from '../stores/scenes.js';
import UiEmpty from '../kit/UiEmpty.vue';
// The selected scene's shots. Rows keep their identity across selection so a // The selected scene's shots. Rows keep their identity across selection so a
// click followed by a double-click lands on the same label. // click followed by a double-click lands on the same label.
@@ -73,9 +74,11 @@ function onRenameKey(shot, event) {
<template> <template>
<div id="scene-shot-list" class="scene-shot-list ui-scroll"> <div id="scene-shot-list" class="scene-shot-list ui-scroll">
<template v-if="attached"> <template v-if="attached">
<div v-if="!scene?.shots.length" class="scene-shot-empty">{{ <UiEmpty
t('No shots yet. Use CAPTURE SHOT to save current look.') v-if="!scene?.shots.length"
}}</div> class="scene-shot-empty"
:title="t('No shots yet. Use CAPTURE SHOT to save current look.')"
/>
<template v-else> <template v-else>
<div <div
v-for="shot in scene.shots" v-for="shot in scene.shots"
+3 -2
View File
@@ -7,6 +7,7 @@ import RailPanel from '../../rail/RailPanel.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';
import UiBadge from '../../kit/UiBadge.vue';
// The CCTV panel, rendered from what CctvControls presents and publishes; // The CCTV panel, rendered from what CctvControls presents and publishes;
// until it renders, the panel shows its initial markup. The controls draw // until it renders, the panel shows its initial markup. The controls draw
@@ -35,7 +36,7 @@ const frameData = computed(() =>
<img id="cctv-frame" alt="CCTV feed frame" :class="{ active: frame?.hasFrame }" :src="frame?.src || undefined" :hidden="live" v-bind="frameData" /> <img id="cctv-frame" alt="CCTV feed frame" :class="{ active: frame?.hasFrame }" :src="frame?.src || undefined" :hidden="live" v-bind="frameData" />
<canvas id="cctv-video" class="cctv-frame" :hidden="!live"></canvas> <canvas id="cctv-video" class="cctv-frame" :hidden="!live"></canvas>
</div> </div>
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" :data-frame-state="sourceBadge?.state">{{ sourceBadge?.label ?? t('SOURCE · UNKNOWN') }}</div> <UiBadge id="cctv-source-badge" role="status" aria-live="polite" :data-frame-state="sourceBadge?.state">{{ sourceBadge?.label ?? t('SOURCE · UNKNOWN') }}</UiBadge>
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div> <div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
<div class="cctv-controls"> <div class="cctv-controls">
<UiButton class="scene-btn" :pressed="Boolean(view?.enabled)" pressed-class="active" id="cctv-enable-btn">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</UiButton> <UiButton class="scene-btn" :pressed="Boolean(view?.enabled)" pressed-class="active" id="cctv-enable-btn">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</UiButton>
@@ -53,7 +54,7 @@ const frameData = computed(() =>
</div> </div>
<div class="cctv-controls"> <div class="cctv-controls">
<UiButton class="scene-btn" :pressed="Boolean(view?.projection.active)" pressed-class="active" id="cctv-projection-btn" :disabled="view?.toggleDisabled">{{ view?.projection.text ?? t('PROJECTION ON') }}</UiButton> <UiButton class="scene-btn" :pressed="Boolean(view?.projection.active)" pressed-class="active" id="cctv-projection-btn" :disabled="view?.toggleDisabled">{{ view?.projection.text ?? t('PROJECTION ON') }}</UiButton>
<span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" :data-cal-badge="view?.quality.calBadge">{{ view?.quality.label ?? t('CAL · {badge}', { badge: '--' }) }}</span> <UiBadge id="cctv-quality-chip" class="gev-status-badge--box" :data-cal-badge="view?.quality.calBadge">{{ view?.quality.label ?? t('CAL · {badge}', { badge: '--' }) }}</UiBadge>
</div> </div>
<div class="cctv-calibration-block"> <div class="cctv-calibration-block">
<div class="cctv-calibration-header"> <div class="cctv-calibration-header">
+3 -2
View File
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia';
import { onBeforeUpdate, onUpdated, ref } from 'vue'; import { onBeforeUpdate, onUpdated, ref } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { useContextStore } from '../../stores/context.js'; import { useContextStore } from '../../stores/context.js';
import UiEmpty from '../../kit/UiEmpty.vue';
// The Space Missions roster. The launches layer presents it; until it does, // The Space Missions roster. The launches layer presents it; until it does,
// the list shows its loading line. Hover and keyboard focus drive the // the list shows its loading line. Hover and keyboard focus drive the
@@ -69,8 +70,8 @@ onUpdated(() => {
<output data-mission-roster-count>{{ roster ? roster.count : '—' }}</output> <output data-mission-roster-count>{{ roster ? roster.count : '—' }}</output>
</div> </div>
<div class="space-mission-roster-list ui-scroll" data-mission-roster-list> <div class="space-mission-roster-list ui-scroll" data-mission-roster-list>
<div v-if="!roster" class="space-mission-roster-empty">{{ t('LOADING 30-DAY MISSION INDEX') }}</div> <UiEmpty v-if="!roster" class="space-mission-roster-empty" :title="t('LOADING 30-DAY MISSION INDEX')" />
<div v-else-if="!roster.entries.length" class="space-mission-roster-empty">{{ roster.empty }}</div> <UiEmpty v-else-if="!roster.entries.length" class="space-mission-roster-empty" :title="roster.empty" />
<button v-for="entry in roster?.entries" v-else :key="entry.id" type="button" class="space-mission-roster-item" :data-mission-roster-index="entry.index" :data-mission-roster-id="entry.id" :aria-label="entry.ariaLabel" @click="layer?.selectMissionAt(entry.index)" @mouseenter="enter(entry.index)" @mousemove="move(entry.index)" @mouseleave="layer?.rosterPointerLeave()" @focus="layer?.rosterFocus(entry.index)" @blur="blur"><span class="space-mission-roster-marker" :style="{ '--mission-roster-color': entry.color }" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>{{ entry.label }}</strong><small>{{ entry.provider }} · {{ entry.date }}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button> <button v-for="entry in roster?.entries" v-else :key="entry.id" type="button" class="space-mission-roster-item" :data-mission-roster-index="entry.index" :data-mission-roster-id="entry.id" :aria-label="entry.ariaLabel" @click="layer?.selectMissionAt(entry.index)" @mouseenter="enter(entry.index)" @mousemove="move(entry.index)" @mouseleave="layer?.rosterPointerLeave()" @focus="layer?.rosterFocus(entry.index)" @blur="blur"><span class="space-mission-roster-marker" :style="{ '--mission-roster-color': entry.color }" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>{{ entry.label }}</strong><small>{{ entry.provider }} · {{ entry.date }}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>
</div> </div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p> <p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>