Skip to content

Commit d9f5550

Browse files
committed
feat: Add animation effect for navigation entries
Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Jan C. Borchardt <925062+jancborchardt@users.noreply.github.com>
1 parent fef17a7 commit d9f5550

3 files changed

Lines changed: 350 additions & 3 deletions

File tree

src/assets/NcAppNavigationItem.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,17 @@
263263
}
264264
}
265265

266+
// When the parent list renders a sliding highlight, suppress the per-entry
267+
// hover/focus background of non-active entries so only the moving highlight
268+
// is visible. Active entries keep their own static highlight and stripe.
269+
// The leading list class keeps these more specific than the rules above.
270+
.app-navigation-list--animated-highlight {
271+
.app-navigation-entry:not(.active):hover,
272+
.app-navigation-entry:not(.active):focus-within {
273+
background-color: transparent !important;
274+
}
275+
}
276+
266277
@keyframes nc-nav-stripe-in {
267278
from {
268279
transform: scaleY(0);

src/components/NcAppNavigationList/NcAppNavigationList.vue

Lines changed: 186 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
List wrapper for use in NcAppNavigation.
1010

11+
The list renders a single hover/focus highlight that slides between entries
12+
instead of every entry painting its own hover background. When it slides onto
13+
the active entry it turns transparent, so the active entry keeps its own
14+
static highlight while the motion stays continuous. If JavaScript does not run
15+
the per-entry hover background is used as a fallback.
16+
1117
#### Example
1218

1319
Usage with NcAppNavigationCaption as a heading.
@@ -30,15 +36,148 @@ Usage with NcAppNavigationCaption as a heading.
3036
</docs>
3137

3238
<template>
33-
<ul class="app-navigation-list">
39+
<ul
40+
ref="list"
41+
class="app-navigation-list"
42+
:class="{ 'app-navigation-list--animated-highlight': enabled }"
43+
@pointerover="handle"
44+
@pointerleave="hide"
45+
@focusin="handle"
46+
@focusout="onFocusOut">
47+
<div
48+
v-if="enabled"
49+
class="app-navigation-list__highlight"
50+
:class="{
51+
'app-navigation-list__highlight--visible': visible,
52+
'app-navigation-list__highlight--animated': animated,
53+
'app-navigation-list__highlight--over-active': overActive,
54+
}"
55+
:style="highlightStyle"
56+
aria-hidden="true" />
3457
<slot />
3558
</ul>
3659
</template>
3760

3861
<script lang="ts">
39-
export default {
62+
import { defineComponent } from 'vue'
63+
64+
export default defineComponent({
4065
name: 'NcAppNavigationList',
41-
}
66+
67+
data() {
68+
return {
69+
/** Whether the moving highlight is active (JS mounted) */
70+
enabled: false,
71+
/** Whether the highlight is currently shown */
72+
visible: false,
73+
/** Whether position changes should transition (slide) or snap */
74+
animated: false,
75+
/** Whether the highlight sits on the active entry (turns transparent) */
76+
overActive: false,
77+
/** Vertical offset of the highlight inside the scrollable content */
78+
top: 0,
79+
/** Height of the highlight */
80+
height: 0,
81+
}
82+
},
83+
84+
computed: {
85+
highlightStyle(): Record<string, string> {
86+
return {
87+
transform: `translateY(${this.top}px)`,
88+
height: `${this.height}px`,
89+
}
90+
},
91+
},
92+
93+
mounted() {
94+
// Progressive enhancement: the sliding highlight only runs once mounted,
95+
// otherwise the per-entry hover background (see styles) is the fallback.
96+
this.enabled = true
97+
},
98+
99+
methods: {
100+
/**
101+
* Show the highlight on the given entry. It slides there if already
102+
* visible, otherwise it snaps into place to avoid sliding in from a
103+
* previously hovered entry. Over the active entry it turns transparent
104+
* so the active entry keeps its own static highlight.
105+
*
106+
* @param entry the entry element to cover
107+
*/
108+
showOn(entry: HTMLElement) {
109+
const list = this.$refs.list as HTMLElement
110+
const entryRect = entry.getBoundingClientRect()
111+
const listRect = list.getBoundingClientRect()
112+
const top = entryRect.top - listRect.top + list.scrollTop
113+
const height = entryRect.height
114+
this.overActive = entry.classList.contains('active')
115+
if (this.visible) {
116+
this.animated = true
117+
this.top = top
118+
this.height = height
119+
return
120+
}
121+
// Re-appearing: snap to the new position without sliding, then fade in
122+
this.animated = false
123+
this.top = top
124+
this.height = height
125+
this.visible = true
126+
this.$nextTick(() => requestAnimationFrame(() => {
127+
this.animated = true
128+
}))
129+
},
130+
131+
/** Hide the highlight */
132+
hide() {
133+
this.visible = false
134+
},
135+
136+
/**
137+
* Find the entry element a given event target belongs to, or null if the
138+
* event did not land on an eligible entry of this list.
139+
*
140+
* @param event the pointer or focus event
141+
*/
142+
entryFromEvent(event: Event): HTMLElement | null {
143+
const target = event.target as HTMLElement | null
144+
const entry = target?.closest<HTMLElement>('.app-navigation-entry')
145+
// Ignore entries that are being edited (they have their own UI)
146+
if (!entry || entry.classList.contains('app-navigation-entry--editing')) {
147+
return null
148+
}
149+
return (this.$refs.list as HTMLElement).contains(entry) ? entry : null
150+
},
151+
152+
/**
153+
* Move the highlight to the entry under the pointer or focus
154+
*
155+
* @param event the pointer or focus event
156+
*/
157+
handle(event: Event) {
158+
const entry = this.entryFromEvent(event)
159+
// Not over an entry (e.g. the gap between entries): keep the current
160+
// state so the highlight can slide across to the next entry. The
161+
// highlight slides onto every entry, including the active one (where it
162+
// becomes transparent), so the motion stays continuous.
163+
if (entry) {
164+
this.showOn(entry)
165+
}
166+
},
167+
168+
/**
169+
* Hide the highlight once focus leaves the list entirely
170+
*
171+
* @param event the focusout event
172+
*/
173+
onFocusOut(event: FocusEvent) {
174+
const list = this.$refs.list as HTMLElement
175+
if (!list.contains(event.relatedTarget as Node | null)) {
176+
this.hide()
177+
}
178+
},
179+
},
180+
})
42181
</script>
43182

44183
<style lang="scss" scoped>
@@ -51,5 +190,49 @@ export default {
51190
flex-direction: column;
52191
gap: var(--default-grid-baseline, 4px);
53192
padding: var(--app-navigation-padding);
193+
isolation: isolate; // keep the highlight layered predictably within the list
194+
195+
&__highlight {
196+
position: absolute;
197+
inset-inline: var(--app-navigation-padding);
198+
top: 0;
199+
height: 0;
200+
// As the first positioned child it paints below the entry wrappers
201+
// (also positioned), so it sits behind the entry content.
202+
z-index: 0;
203+
pointer-events: none;
204+
opacity: 0;
205+
border-radius: var(--border-radius-element);
206+
// Matches the per-entry hover background of non-legacy entries.
207+
background-color: color-mix(in srgb, var(--color-primary-element) 8%, transparent);
208+
will-change: transform, height;
209+
// The fade and the background morph are always transitioned; sliding is
210+
// opt-in via --animated so the highlight snaps when it (re)appears.
211+
transition:
212+
opacity var(--animation-quick) ease-in-out,
213+
background-color var(--animation-quick) ease-in-out;
214+
215+
&--animated {
216+
transition:
217+
transform var(--animation-quick) ease-in-out,
218+
height var(--animation-quick) ease-in-out,
219+
opacity var(--animation-quick) ease-in-out,
220+
background-color var(--animation-quick) ease-in-out;
221+
}
222+
223+
&--visible {
224+
opacity: 1;
225+
}
226+
227+
// Over the active entry the highlight turns transparent so the active
228+
// entry's own static highlight shows through unchanged, while the
229+
// highlight still slides on and off it for a continuous motion.
230+
&--over-active {
231+
background-color: transparent;
232+
}
233+
}
234+
// Reduced motion is handled globally: the --animation-quick variable is
235+
// collapsed under a prefers-reduced-motion media query by the server theme,
236+
// so these transitions become instant without a component-level override.
54237
}
55238
</style>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { mount } from '@vue/test-utils'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { nextTick } from 'vue'
9+
import NcAppNavigationList from '../../../../src/components/NcAppNavigationList/NcAppNavigationList.vue'
10+
11+
/**
12+
* Stub getBoundingClientRect, which jsdom always reports as zero.
13+
*
14+
* @param {Element} el the element to stub
15+
* @param {number} top the top offset to report
16+
* @param {number} height the height to report
17+
*/
18+
function setRect(el, top, height) {
19+
el.getBoundingClientRect = () => ({
20+
top,
21+
height,
22+
bottom: top + height,
23+
left: 0,
24+
right: 0,
25+
width: 0,
26+
x: 0,
27+
y: top,
28+
toJSON: () => ({}),
29+
})
30+
}
31+
32+
/**
33+
* Mount the list with three entries: a plain one, the active one and an entry
34+
* that is being edited, each with a stubbed geometry.
35+
*/
36+
function mountList() {
37+
const wrapper = mount(NcAppNavigationList, {
38+
slots: {
39+
default: '<div class="app-navigation-entry" data-id="one">One</div>'
40+
+ '<div class="app-navigation-entry active" data-id="two">Two</div>'
41+
+ '<div class="app-navigation-entry app-navigation-entry--editing" data-id="three">Three</div>',
42+
},
43+
})
44+
setRect(wrapper.element, 0, 300)
45+
const entries = wrapper.findAll('.app-navigation-entry')
46+
setRect(entries[0].element, 0, 44)
47+
setRect(entries[1].element, 50, 44)
48+
setRect(entries[2].element, 100, 44)
49+
return wrapper
50+
}
51+
52+
describe('NcAppNavigationList.vue', () => {
53+
let rafQueue = []
54+
55+
beforeEach(() => {
56+
rafQueue = []
57+
vi.stubGlobal('requestAnimationFrame', (cb) => rafQueue.push(cb))
58+
})
59+
60+
afterEach(() => {
61+
vi.unstubAllGlobals()
62+
})
63+
64+
/** Run the queued requestAnimationFrame callbacks. */
65+
const flushRaf = () => {
66+
const queued = rafQueue
67+
rafQueue = []
68+
queued.forEach((cb) => cb())
69+
}
70+
71+
it('enables the sliding highlight once mounted', async () => {
72+
const wrapper = mountList()
73+
expect(wrapper.vm.enabled).toBe(true)
74+
await nextTick()
75+
expect(wrapper.get('.app-navigation-list__highlight')).toBeTruthy()
76+
})
77+
78+
it('shows and positions the highlight over the hovered entry', async () => {
79+
const wrapper = mountList()
80+
81+
await wrapper.find('[data-id="one"]').trigger('pointerover')
82+
83+
expect(wrapper.vm.visible).toBe(true)
84+
expect(wrapper.vm.top).toBe(0)
85+
expect(wrapper.vm.height).toBe(44)
86+
expect(wrapper.vm.overActive).toBe(false)
87+
})
88+
89+
it('snaps when re-appearing, then enables sliding', async () => {
90+
const wrapper = mountList()
91+
92+
await wrapper.find('[data-id="one"]').trigger('pointerover')
93+
// Snapped into place: no transition yet, the rAF has not run
94+
expect(wrapper.vm.animated).toBe(false)
95+
96+
flushRaf()
97+
await nextTick()
98+
// Sliding is enabled for subsequent moves
99+
expect(wrapper.vm.animated).toBe(true)
100+
})
101+
102+
it('slides (animates) when moving while already visible', async () => {
103+
const wrapper = mountList()
104+
105+
await wrapper.find('[data-id="one"]').trigger('pointerover')
106+
await wrapper.find('[data-id="two"]').trigger('pointerover')
107+
108+
expect(wrapper.vm.animated).toBe(true)
109+
expect(wrapper.vm.top).toBe(50)
110+
})
111+
112+
it('turns transparent over the active entry', async () => {
113+
const wrapper = mountList()
114+
115+
await wrapper.find('[data-id="two"]').trigger('pointerover')
116+
117+
expect(wrapper.vm.overActive).toBe(true)
118+
expect(wrapper.get('.app-navigation-list__highlight').classes())
119+
.toContain('app-navigation-list__highlight--over-active')
120+
})
121+
122+
it('ignores entries that are being edited', async () => {
123+
const wrapper = mountList()
124+
125+
await wrapper.find('[data-id="three"]').trigger('pointerover')
126+
127+
expect(wrapper.vm.visible).toBe(false)
128+
})
129+
130+
it('hides the highlight when the pointer leaves the list', async () => {
131+
const wrapper = mountList()
132+
133+
await wrapper.find('[data-id="one"]').trigger('pointerover')
134+
expect(wrapper.vm.visible).toBe(true)
135+
136+
await wrapper.trigger('pointerleave')
137+
expect(wrapper.vm.visible).toBe(false)
138+
})
139+
140+
it('hides only when focus leaves the list entirely', async () => {
141+
const wrapper = mountList()
142+
await wrapper.find('[data-id="one"]').trigger('focusin')
143+
expect(wrapper.vm.visible).toBe(true)
144+
145+
// Focus moving to another entry inside the list keeps it visible
146+
await wrapper.find('[data-id="two"]').element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: wrapper.find('[data-id="one"]').element }))
147+
expect(wrapper.vm.visible).toBe(true)
148+
149+
// Focus leaving the list hides it
150+
await wrapper.find('[data-id="two"]').element.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: document.body }))
151+
expect(wrapper.vm.visible).toBe(false)
152+
})
153+
})

0 commit comments

Comments
 (0)