88
99List 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
1319Usage 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 >
0 commit comments