Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/assets/NcAppNavigationItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
242 changes: 239 additions & 3 deletions src/components/NcAppNavigationList/NcAppNavigationList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -30,15 +36,201 @@ Usage with NcAppNavigationCaption as a heading.
</docs>

<template>
<ul class="app-navigation-list">
<ul
ref="list"
class="app-navigation-list"
:class="{ 'app-navigation-list--animated-highlight': enabled }"
@pointerover="onPointerOver"
@pointerleave="hide"
@focusin="onFocusIn"
@focusout="onFocusOut">
<div
v-if="enabled"
class="app-navigation-list__highlight"
:class="{
'app-navigation-list__highlight--visible': visible,
'app-navigation-list__highlight--animated': animated,
'app-navigation-list__highlight--over-active': overActive,
}"
:style="highlightStyle"
aria-hidden="true" />
<slot />
</ul>
</template>

<script lang="ts">
export default {
import { defineComponent } from 'vue'

export default defineComponent({
name: 'NcAppNavigationList',
}

data() {
return {
/** Whether the moving highlight is active (JS mounted) */
enabled: false,
/** Whether the highlight is currently shown */
visible: false,
/** Whether position changes should transition (slide) or snap */
animated: false,
/** Whether the highlight is over the active entry (turns transparent) */
overActive: false,
/** Vertical offset of the highlight inside the scrollable content */
top: 0,
/** Height of the highlight */
height: 0,
/** The entry the highlight is currently shown on */
currentEntry: null as HTMLElement | null,
/** Observer hiding the highlight when its entry becomes active or leaves */
observer: null as MutationObserver | null,
}
},

computed: {
highlightStyle(): Record<string, string> {
return {
transform: `translateY(${this.top}px)`,
height: `${this.height}px`,
}
},
},

mounted() {
this.enabled = true
// Keep the highlight in sync when the entry it sits on changes: hide it
// if that entry is removed, or turn it transparent if it becomes active
// (e.g. on click) so it morphs into the active highlight without jumping.
this.observer = new MutationObserver(() => {
if (!this.currentEntry) {
return
}
if (!(this.$refs.list as HTMLElement).contains(this.currentEntry)) {
this.hide()
return
}
this.overActive = this.currentEntry.classList.contains('active')
})
this.observer.observe(this.$refs.list as HTMLElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class'],
})
window.addEventListener('resize', this.reposition)
},

beforeUnmount() {
this.observer?.disconnect()
window.removeEventListener('resize', this.reposition)
},

methods: {
/**
* Measure where the given entry sits inside the scrollable content
*
* @param entry the entry element to measure
*/
measure(entry: HTMLElement): { top: number, height: number } {
const list = this.$refs.list as HTMLElement
const entryRect = entry.getBoundingClientRect()
const listRect = list.getBoundingClientRect()
return {
top: entryRect.top - listRect.top + list.scrollTop,
height: entryRect.height,
}
},

/**
* Show the highlight on the given entry. It slides there if already
* visible, otherwise it snaps into place to avoid sliding in from a
* previously hovered entry. Over the active entry it turns transparent
* so the active entry keeps its own static highlight.
*
* @param entry the entry element to cover
*/
showOn(entry: HTMLElement) {
const { top, height } = this.measure(entry)
this.currentEntry = entry
this.overActive = entry.classList.contains('active')
if (this.visible) {
this.animated = true
this.top = top
this.height = height
return
}
// Re-appearing: snap to the new position without sliding, then fade in
this.animated = false
this.top = top
this.height = height
this.visible = true
this.$nextTick(() => requestAnimationFrame(() => {
this.animated = true
}))
},

/** Hide the highlight */
hide() {
this.visible = false
this.currentEntry = null
},

/** Keep the highlight aligned with its entry on layout changes */
reposition() {
if (this.visible && this.currentEntry) {
const { top, height } = this.measure(this.currentEntry)
this.top = top
this.height = height
}
},

/**
* Find the entry element a given event target belongs to
*
* @param event the pointer or focus event
*/
entryFromEvent(event: Event): HTMLElement | null {
const target = event.target as HTMLElement | null
const entry = target?.closest<HTMLElement>('.app-navigation-entry')
// Ignore entries that are being edited (they have their own UI)
if (!entry || entry.classList.contains('app-navigation-entry--editing')) {
return null
}
return (this.$refs.list as HTMLElement).contains(entry) ? entry : null
},

/**
* React to a pointer/focus landing on an entry
*
* @param event the pointer or focus event
*/
handle(event: Event) {
const entry = this.entryFromEvent(event)
// Not over an entry (e.g. the gap between entries): keep the current
// state so the highlight can slide across to the next entry.
if (!entry) {
return
}
// The highlight slides onto every entry, including the active one
// (where it becomes transparent), so the motion stays continuous.
this.showOn(entry)
},

onPointerOver(event: PointerEvent) {
this.handle(event)
},

onFocusIn(event: FocusEvent) {
this.handle(event)
},

onFocusOut(event: FocusEvent) {
const list = this.$refs.list as HTMLElement
// Hide once focus leaves the list entirely
if (!list.contains(event.relatedTarget as Node | null)) {
this.hide()
}
},
},
})
</script>

<style lang="scss" scoped>
Expand All @@ -51,5 +243,49 @@ export default {
flex-direction: column;
gap: var(--default-grid-baseline, 4px);
padding: var(--app-navigation-padding);
isolation: isolate; // keep the highlight layered predictably within the list

&__highlight {
position: absolute;
inset-inline: var(--app-navigation-padding);
top: 0;
height: 0;
// As the first positioned child it paints below the entry wrappers
// (also positioned), so it sits behind the entry content.
z-index: 0;
pointer-events: none;
opacity: 0;
border-radius: var(--border-radius-element);
// Matches the per-entry hover background of non-legacy entries.
background-color: color-mix(in srgb, var(--color-primary-element) 8%, transparent);
will-change: transform, height;
// The fade and the background morph are always transitioned; sliding is
// opt-in via --animated so the highlight snaps when it (re)appears.
transition:
opacity var(--animation-quick) ease-in-out,
background-color var(--animation-quick) ease-in-out;

&--animated {
transition:
transform var(--animation-quick) ease-in-out,
height var(--animation-quick) ease-in-out,
opacity var(--animation-quick) ease-in-out,
background-color var(--animation-quick) ease-in-out;
}

&--visible {
opacity: 1;
}

// Over the active entry the highlight turns transparent so the active
// entry's own static highlight shows through unchanged, while the
// highlight still slides on and off it for a continuous motion.
&--over-active {
background-color: transparent;
}
}
// Reduced motion is handled globally: the --animation-quick variable is
// collapsed under a prefers-reduced-motion media query by the server theme,
// so these transitions become instant without a component-level override.
}
</style>
Loading