Merge branch 'main' into fix/b-awareness-marker-km

This commit is contained in:
Sameh Khamis committed 2026-11-01 22:47:58 -08:00
commit 950d1b78ce
8 files changed
+198 -9

No files matched your search

+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,