From d546d65380bb9247d783f9c694da6fb9fac949eb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:45:03 -0800 Subject: [PATCH 1/2] 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. --- docs/CURRENT-STATE.md | 4 +++ src/ui/PanelRailView.vue | 1 + src/ui/kit/UiRail.test.mjs | 72 ++++++++++++++++++++++++++++++++++++++ src/ui/kit/UiRail.vue | 61 ++++++++++++++++++++++++++++++-- src/ui/rtlLayout.test.mjs | 14 +++++--- src/ui/styles/kit.css | 45 ++++++++++++++++++++++++ src/ui/styles/layers.css | 7 ++-- 7 files changed, 195 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 567de41e4..a89d18892 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/ui/PanelRailView.vue b/src/ui/PanelRailView.vue index 8f43dabb2..8ac9fb256 100644 --- a/src/ui/PanelRailView.vue +++ b/src/ui/PanelRailView.vue @@ -42,6 +42,7 @@ const label = computed(() => :side="side" :label="label" :panel-open="active !== null" + collapsible @select="onSelect" /> diff --git a/src/ui/kit/UiRail.test.mjs b/src/ui/kit/UiRail.test.mjs index 6c443286a..57a0ff09d 100644 --- a/src/ui/kit/UiRail.test.mjs +++ b/src/ui/kit/UiRail.test.mjs @@ -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, + ); +}); diff --git a/src/ui/kit/UiRail.vue b/src/ui/kit/UiRail.vue index af740aae3..4e2bf6836 100644 --- a/src/ui/kit/UiRail.vue +++ b/src/ui/kit/UiRail.vue @@ -1,9 +1,10 @@