feat(kit): show the panel rails as labelled pills until a panel opens

UiRail takes `collapsible`: while no panel on its side is open, each item
shows its icon and label as a pill, and once one opens the labels fold away
to the icon rail, making room for the panel. The tip is not shown while the
label is on screen; the button keeps its accessible name and the visible
label is hidden from assistive technology. The side panel rails turn it on.

Each rail is now placed by its screen-side edge, at the rail clearance, so a
rail that widens grows toward the middle of the screen; collapsed, it sits
6px outside its stack as before.
This commit is contained in:
Sameh Khamis committed 2026-11-01 22:40:17 -08:00
1 parent 6acaa01ca8
commit d546d65380
7 files changed
+195 -9

No files matched your search

+4
View File
@@ -1582,6 +1582,10 @@ Imagery (while shown), Street Level and Context on the right. Selecting an item
opens its panel through `setPanelCollapsed(id, false, {explicit: true})`;
selecting the open panel's item closes it. The item of the open panel is
pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open.
While no panel on its side is open, the rail shows each item as a labelled pill
(icon and title, `UiRail`'s `collapsible`); opening a panel folds the labels away
to the icon rail. The rail is placed by its screen-side edge, so the pills grow
toward the middle of the screen, over the closed side's empty corridor.
One panel is open per desktop side, in every HUD layout. Opening a side panel
by any route (rail, header, keyboard, voice) collapses its peers; restored or
+1
View File
@@ -42,6 +42,7 @@ const label = computed(() =>
:side="side"
:label="label"
:panel-open="active !== null"
collapsible
@select="onSelect"
/>
</template>
+72
View File
@@ -71,3 +71,75 @@ test('tips open toward the screen, and not for the open panel', async () => {
await other.trigger('focusout');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
test('a collapsible rail shows labelled pills until a panel opens', async () => {
const wrapper = mountRail({ collapsible: true });
const nav = wrapper.get('nav');
assert.ok(nav.classes().includes('ui-rail--collapsible'));
assert.ok(nav.classes().includes('ui-rail--expanded'));
assert.deepEqual(
wrapper.findAll('.ui-rail__label').map((label) => label.text()),
['DATA LAYERS', 'AUTHORING', 'CCTV'],
);
// The label is on screen, so its tip would only repeat it.
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
// The button keeps its own accessible name; the visible label is hidden
// from assistive technology so the name is not read twice.
const button = wrapper.get('[data-rail-item="layers"]');
assert.equal(button.attributes('aria-label'), 'DATA LAYERS');
assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true');
await wrapper.setProps({ active: 'layers', panelOpen: true });
assert.ok(nav.classes().includes('ui-rail--collapsible'));
assert.equal(nav.classes().includes('ui-rail--expanded'), false);
// Collapsed, the other items' tips come back.
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
await new Promise((resolve) => setTimeout(resolve, 450));
assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING');
});
test('a label cut short by the pill keeps its tip', async (t) => {
const observers = [];
const original = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
unobserve() {}
disconnect() {}
};
t.after(() => (globalThis.ResizeObserver = original));
const wrapper = mountRail({ collapsible: true });
const [observer] = observers;
const [layers, scenes] = observer.targets;
Object.defineProperties(layers, {
scrollWidth: { value: 260 },
clientWidth: { value: 220 },
});
Object.defineProperties(scenes, {
scrollWidth: { value: 90 },
clientWidth: { value: 90 },
});
observer.callback([{ target: layers }, { target: scenes }]);
await wrapper.vm.$nextTick();
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS');
await wrapper.findAll('.ui-tooltip')[0].trigger('focusout');
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
});
test('a plain rail has no labels', () => {
const wrapper = mountRail();
assert.equal(wrapper.find('.ui-rail__label').exists(), false);
assert.equal(
wrapper.get('nav').classes().includes('ui-rail--expanded'),
false,
);
});
+58 -3
View File
@@ -1,9 +1,10 @@
<script setup>
import { computed, onBeforeUnmount, reactive } from 'vue';
import UiIcon from './UiIcon.vue';
import UiTooltip from './UiTooltip.vue';
import { RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS } from './rail.js';
defineProps({
const props = defineProps({
/** @type {import('vue').PropType<import('./rail.js').UiRailItem[]>} */
items: { type: Array, required: true },
/** The selected item's id, or null. */
@@ -17,18 +18,65 @@ defineProps({
* panel's title, and the other tips wait for the pointer to rest.
*/
panelOpen: { type: Boolean, default: false },
/**
* Show each item as a labelled pill while no panel is open, and collapse to
* icons once one is, making room for the panel.
*/
collapsible: { type: Boolean, default: false },
});
const emit = defineEmits(['select']);
const expanded = computed(() => props.collapsible && !props.panelOpen);
// A label cut short by the pill's width keeps its tip, so the full name can
// still be read. Each label is measured whenever its box changes size, which
// includes the moment it settles after expanding.
const truncated = reactive(new Set());
const labelIds = new WeakMap();
const labelElements = new Map();
const labelObserver =
typeof ResizeObserver === 'function'
? new ResizeObserver((entries) => {
for (const { target } of entries) {
const id = labelIds.get(target);
if (target.scrollWidth > target.clientWidth) truncated.add(id);
else truncated.delete(id);
}
})
: null;
function trackLabel(id, element) {
const previous = labelElements.get(id);
if (previous === element) return;
if (previous) {
labelObserver?.unobserve(previous);
labelElements.delete(id);
truncated.delete(id);
}
if (!element) return;
labelElements.set(id, element);
labelIds.set(element, id);
labelObserver?.observe(element);
}
onBeforeUnmount(() => labelObserver?.disconnect());
const tipHidden = (item) =>
(expanded.value && !truncated.has(item.id)) ||
(props.panelOpen && item.id === props.active);
</script>
<template>
<nav class="ui-rail" :class="`ui-rail--${side}`" :aria-label="label">
<nav
class="ui-rail"
:class="[
`ui-rail--${side}`,
{ 'ui-rail--collapsible': collapsible, 'ui-rail--expanded': expanded },
]"
:aria-label="label"
>
<template v-for="item in items" :key="item.id">
<i v-if="item.separatorBefore" class="ui-rail__sep" aria-hidden="true" />
<UiTooltip
:text="item.label"
:side="side === 'left' ? 'right' : 'left'"
:disabled="panelOpen && item.id === active"
:disabled="tipHidden(item)"
:delay="panelOpen ? RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS : 0"
>
<button
@@ -42,6 +90,13 @@ const emit = defineEmits(['select']);
@click="emit('select', item.id)"
>
<UiIcon :name="item.icon" />
<span
v-if="collapsible"
:ref="(element) => trackLabel(item.id, element)"
class="ui-rail__label"
aria-hidden="true"
>{{ item.label }}</span
>
<span v-if="item.badge !== undefined" class="ui-rail__badge">{{
item.badge
}}</span>
+10 -4
View File
@@ -66,13 +66,19 @@ test('the Cyber cockpit keeps Data Layers on the left edge in every language', (
test('the panel rails stay on the outer side of their stacks in right-to-left', () => {
const layers = sheet('layers.css');
// Each rail is placed against its stack's outer edge; a physical side
// would put it over the panels once the stacks swap sides.
// Each rail is placed by its screen-side edge; a physical side would put
// it over the panels once the stacks swap sides.
const left = ruleBody(layers, '.gev-panel-rail--left');
assert.match(left, /inset-inline-end: calc\(100% \+ 6px\);/);
assert.match(
left,
/inset-inline-start: calc\(-1 \* var\(--panel-rail-clearance\)\);/,
);
assert.doesNotMatch(left, /(?:^|\s)(?:left|right):/);
const right = ruleBody(layers, '.gev-panel-rail--right');
assert.match(right, /inset-inline-start: calc\(100% \+ 6px\);/);
assert.match(
right,
/inset-inline-end: calc\(-1 \* var\(--panel-rail-clearance\)\);/,
);
assert.doesNotMatch(right, /(?:^|\s)(?:left|right):/);
// Their tips open toward the middle of the screen.
const kit = sheet('kit.css');
+45
View File
@@ -116,6 +116,51 @@
opacity: 0.35;
cursor: default;
}
/* A collapsible rail shows each item as an icon and its label while no panel
is open, and folds the labels away once one is. The collapsed item keeps
the plain rail's 32px square. */
.ui-rail--collapsible {
transition: border-radius var(--transition-fast);
}
.ui-rail--collapsible .ui-rail__item {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-width: 32px;
padding: 0 6px;
transition:
color var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast),
border-radius var(--transition-fast);
}
.ui-rail__label {
max-width: 0;
overflow: hidden;
opacity: 0;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--dock-label-font-weight) var(--dock-label-font-size) / 1
var(--font-sans);
letter-spacing: var(--dock-label-letter-spacing);
transition:
max-width var(--transition-fast),
margin var(--transition-fast),
opacity var(--transition-fast);
}
.ui-rail--expanded {
border-radius: 21px;
}
.ui-rail--expanded .ui-rail__item {
border-radius: 16px;
}
.ui-rail--expanded .ui-rail__label {
max-width: 220px;
margin-inline: 8px 8px;
opacity: 1;
}
.ui-rail__badge {
position: absolute;
top: 1px;
+5 -2
View File
@@ -255,11 +255,14 @@
pointer-events: auto;
transition: top var(--transition-fast);
}
/* Each rail keeps its outer edge at the screen-side clearance, so a rail that
widens (labelled pills while its side has no open panel) grows toward the
middle of the screen. Collapsed, it sits 6px outside its stack. */
.gev-panel-rail--left {
inset-inline-end: calc(100% + 6px);
inset-inline-start: calc(-1 * var(--panel-rail-clearance));
}
.gev-panel-rail--right {
inset-inline-start: calc(100% + 6px);
inset-inline-end: calc(-1 * var(--panel-rail-clearance));
}
body.mobile-shell #left-panel-stack > .gev-panel-rail,
body.mobile-shell #right-context-rail > .gev-panel-rail,