diff --git a/src/assets/NcAppNavigationItem.scss b/src/assets/NcAppNavigationItem.scss index 6aa5b47523..f30486bf92 100644 --- a/src/assets/NcAppNavigationItem.scss +++ b/src/assets/NcAppNavigationItem.scss @@ -263,6 +263,17 @@ } } +// When the parent list renders a sliding highlight, suppress the per-entry +// hover/focus background of non-active entries so only the moving highlight +// is visible. Active entries keep their own static highlight and stripe. +// The leading list class keeps these more specific than the rules above. +.app-navigation-list--animated-highlight { + .app-navigation-entry:not(.active):hover, + .app-navigation-entry:not(.active):focus-within { + background-color: transparent !important; + } +} + @keyframes nc-nav-stripe-in { from { transform: scaleY(0); diff --git a/src/components/NcAppNavigationList/NcAppNavigationList.vue b/src/components/NcAppNavigationList/NcAppNavigationList.vue index 75e0576a33..681434f55b 100644 --- a/src/components/NcAppNavigationList/NcAppNavigationList.vue +++ b/src/components/NcAppNavigationList/NcAppNavigationList.vue @@ -8,6 +8,12 @@ List wrapper for use in NcAppNavigation. +The list renders a single hover/focus highlight that slides between entries +instead of every entry painting its own hover background. When it slides onto +the active entry it turns transparent, so the active entry keeps its own +static highlight while the motion stays continuous. If JavaScript does not run +the per-entry hover background is used as a fallback. + #### Example Usage with NcAppNavigationCaption as a heading. @@ -30,15 +36,148 @@ Usage with NcAppNavigationCaption as a heading. diff --git a/tests/unit/components/NcAppNavigationList/NcAppNavigationList.spec.js b/tests/unit/components/NcAppNavigationList/NcAppNavigationList.spec.js new file mode 100644 index 0000000000..0afd64d341 --- /dev/null +++ b/tests/unit/components/NcAppNavigationList/NcAppNavigationList.spec.js @@ -0,0 +1,153 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import NcAppNavigationList from '../../../../src/components/NcAppNavigationList/NcAppNavigationList.vue' + +/** + * Stub getBoundingClientRect, which jsdom always reports as zero. + * + * @param {Element} el the element to stub + * @param {number} top the top offset to report + * @param {number} height the height to report + */ +function setRect(el, top, height) { + el.getBoundingClientRect = () => ({ + top, + height, + bottom: top + height, + left: 0, + right: 0, + width: 0, + x: 0, + y: top, + toJSON: () => ({}), + }) +} + +/** + * Mount the list with three entries: a plain one, the active one and an entry + * that is being edited, each with a stubbed geometry. + */ +function mountList() { + const wrapper = mount(NcAppNavigationList, { + slots: { + default: '
One
' + + '
Two
' + + '
Three
', + }, + }) + setRect(wrapper.element, 0, 300) + const entries = wrapper.findAll('.app-navigation-entry') + setRect(entries[0].element, 0, 44) + setRect(entries[1].element, 50, 44) + setRect(entries[2].element, 100, 44) + return wrapper +} + +describe('NcAppNavigationList.vue', () => { + let rafQueue = [] + + beforeEach(() => { + rafQueue = [] + vi.stubGlobal('requestAnimationFrame', (cb) => rafQueue.push(cb)) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + /** Run the queued requestAnimationFrame callbacks. */ + const flushRaf = () => { + const queued = rafQueue + rafQueue = [] + queued.forEach((cb) => cb()) + } + + it('enables the sliding highlight once mounted', async () => { + const wrapper = mountList() + expect(wrapper.vm.enabled).toBe(true) + await nextTick() + expect(wrapper.get('.app-navigation-list__highlight')).toBeTruthy() + }) + + it('shows and positions the highlight over the hovered entry', async () => { + const wrapper = mountList() + + await wrapper.find('[data-id="one"]').trigger('pointerover') + + expect(wrapper.vm.visible).toBe(true) + expect(wrapper.vm.top).toBe(0) + expect(wrapper.vm.height).toBe(44) + expect(wrapper.vm.overActive).toBe(false) + }) + + it('snaps when re-appearing, then enables sliding', async () => { + const wrapper = mountList() + + await wrapper.find('[data-id="one"]').trigger('pointerover') + // Snapped into place: no transition yet, the rAF has not run + expect(wrapper.vm.animated).toBe(false) + + flushRaf() + await nextTick() + // Sliding is enabled for subsequent moves + expect(wrapper.vm.animated).toBe(true) + }) + + it('slides (animates) when moving while already visible', async () => { + const wrapper = mountList() + + await wrapper.find('[data-id="one"]').trigger('pointerover') + await wrapper.find('[data-id="two"]').trigger('pointerover') + + expect(wrapper.vm.animated).toBe(true) + expect(wrapper.vm.top).toBe(50) + }) + + it('turns transparent over the active entry', async () => { + const wrapper = mountList() + + await wrapper.find('[data-id="two"]').trigger('pointerover') + + expect(wrapper.vm.overActive).toBe(true) + expect(wrapper.get('.app-navigation-list__highlight').classes()) + .toContain('app-navigation-list__highlight--over-active') + }) + + it('ignores entries that are being edited', async () => { + const wrapper = mountList() + + await wrapper.find('[data-id="three"]').trigger('pointerover') + + expect(wrapper.vm.visible).toBe(false) + }) + + it('hides the highlight when the pointer leaves the list', async () => { + const wrapper = mountList() + + await wrapper.find('[data-id="one"]').trigger('pointerover') + expect(wrapper.vm.visible).toBe(true) + + await wrapper.trigger('pointerleave') + expect(wrapper.vm.visible).toBe(false) + }) + + it('hides only when focus leaves the list entirely', async () => { + const wrapper = mountList() + await wrapper.find('[data-id="one"]').trigger('focusin') + expect(wrapper.vm.visible).toBe(true) + + // Focus moving to another entry inside the list keeps it visible + await wrapper.find('[data-id="two"]').element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: wrapper.find('[data-id="one"]').element })) + expect(wrapper.vm.visible).toBe(true) + + // Focus leaving the list hides it + await wrapper.find('[data-id="two"]').element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body })) + expect(wrapper.vm.visible).toBe(false) + }) +})