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

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