mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1501 from bilawalsidhu/sameh/rail-pills
Show the panel rails as labelled pills until a panel opens
This commit is contained in:
8 files changed
+198
-9
No files matched your search
@@ -0,0 +1,3 @@
|
||||
- The side panel rails show each panel as a labelled pill while no panel on
|
||||
that side is open, and fold to icons once one opens. `UiRail` takes
|
||||
`collapsible` for this (samehkhamis, #1501).
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@ const label = computed(() =>
|
||||
:side="side"
|
||||
:label="label"
|
||||
:panel-open="active !== null"
|
||||
collapsible
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user