236 lines
7.0 KiB
Vue
236 lines
7.0 KiB
Vue
<template>
|
|
<nav
|
|
class="breadcrumb"
|
|
aria-label="Breadcrumb"
|
|
@keydown.left.stop="move(-1)"
|
|
@keydown.right.stop="move(1)"
|
|
@keyup.enter="move(0)"
|
|
@focus=focusCurrent
|
|
tabindex=0
|
|
>
|
|
<a :href="`/#${urlAt(0)}`"
|
|
:ref="el => setLinkRef(0, el)"
|
|
class="home"
|
|
:class="{ current: !!isCurrent(0) }"
|
|
:aria-current="isCurrent(0)"
|
|
@click.prevent="navigate(0)"
|
|
@mouseenter="homeTooltip?.startHover"
|
|
@mousemove="homeTooltip?.updatePosition"
|
|
@mouseleave="homeTooltip?.endHover"
|
|
>
|
|
<component :is="home" />
|
|
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
|
</a>
|
|
<template v-for="(location, index) in longest" :key="index">
|
|
<a :href="`/#${urlAt(index + 1)}`"
|
|
:class="{ current: !!isCurrent(index + 1) }"
|
|
:aria-current="isCurrent(index + 1)"
|
|
@click.prevent="navigate(index + 1)"
|
|
:ref="el => setLinkRef(index + 1, el)"
|
|
@mouseenter="pathTooltips.get(index)?.startHover"
|
|
@mousemove="pathTooltips.get(index)?.updatePosition"
|
|
@mouseleave="pathTooltips.get(index)?.endHover"
|
|
>{{ location }}<CursorTooltip :ref="el => setPathTooltipRef(index, el)" :text="`/${longest.slice(0, index + 1).join('/')}`">{{ `/${longest.slice(0, index + 1).join('/')}` }}</CursorTooltip></a>
|
|
</template>
|
|
</nav>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Home } from '@/assets/svg'
|
|
import { exists } from '@/utils/fileutil'
|
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import CursorTooltip from './CursorTooltip.vue'
|
|
|
|
const home = Home
|
|
const router = useRouter()
|
|
|
|
const links = [] as Array<HTMLElement>
|
|
const setLinkRef = (index: number, el: any) => {
|
|
if (el) links[index] = el
|
|
}
|
|
onBeforeUpdate(() => {
|
|
links.length = 1
|
|
}) // 1 to keep home
|
|
|
|
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
|
const setPathTooltipRef = (index: number, el: any) => {
|
|
if (el) pathTooltips.value.set(index, el)
|
|
else pathTooltips.value.delete(index)
|
|
}
|
|
|
|
const props = defineProps<{
|
|
path: Array<string>
|
|
links?: Array<string>
|
|
primary?: boolean
|
|
}>()
|
|
|
|
const longest = ref<Array<string>>([])
|
|
const longestLinks = ref<Array<string>>(['/'])
|
|
|
|
const defaultLinks = (segments: Array<string>) => [
|
|
'/',
|
|
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
|
|
]
|
|
|
|
const isCurrent = (index: number) =>
|
|
index == props.path.length ? 'location' : undefined
|
|
|
|
const focusCurrent = () => {
|
|
nextTick(() => {
|
|
const index = props.path.length
|
|
if (index < links.length) links[index]!.focus()
|
|
})
|
|
}
|
|
|
|
const urlAt = (index: number) => {
|
|
const explicit = longestLinks.value[index]
|
|
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
|
|
}
|
|
|
|
const navigate = (index: number) => {
|
|
const link = links[index]
|
|
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
|
const url = urlAt(index)
|
|
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
|
// Clicking on current link clears the rest of the path and adds new history
|
|
if (isCurrent(index)) {
|
|
longest.value.splice(index)
|
|
longestLinks.value.splice(index + 1)
|
|
router.push(u)
|
|
}
|
|
// Moving along breadcrumbs doesn't create new history
|
|
else if (long.startsWith(browser)) router.replace(u)
|
|
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
|
else router.push(u)
|
|
}
|
|
|
|
const move = (dir: number) => {
|
|
const index = props.path.length + dir
|
|
if (index < 0 || index > longest.value.length) return
|
|
navigate(index)
|
|
}
|
|
|
|
watchEffect(() => {
|
|
const currentLinks = props.links ?? defaultLinks(props.path)
|
|
const longcut = longest.value.slice(0, props.path.length)
|
|
const same = longcut.every((value, index) => value === props.path[index])
|
|
// Navigated out of previous path, reset longest to current
|
|
if (!same) {
|
|
longest.value = props.path
|
|
longestLinks.value = currentLinks
|
|
} else if (props.path.length > longcut.length) {
|
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
|
} else {
|
|
// Prune deleted folders from longest
|
|
for (let i = props.path.length; i < longest.value.length; ++i) {
|
|
if (!exists(longest.value.slice(0, i + 1))) {
|
|
longest.value = longest.value.slice(0, i)
|
|
longestLinks.value = longestLinks.value.slice(0, i + 1)
|
|
break
|
|
}
|
|
}
|
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
|
}
|
|
// If needed, focus primary navigation to new location
|
|
if (props.primary)
|
|
nextTick(() => {
|
|
const act = document.activeElement as HTMLElement
|
|
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
:root {
|
|
--breadcrumb-background-odd: #2d2d2d;
|
|
--breadcrumb-background-even: #404040;
|
|
--breadcrumb-color: #ddd;
|
|
--breadcrumb-hover-color: #fff;
|
|
--breadcrumb-hover-background-odd: #25a;
|
|
--breadcrumb-hover-background-even: #812;
|
|
--breadcrumb-transtime: 0.3s;
|
|
}
|
|
.breadcrumb {
|
|
flex: 1 1 auto;
|
|
display: flex;
|
|
min-width: 20%;
|
|
max-width: 100%;
|
|
min-height: 2em;
|
|
margin: 0;
|
|
padding: 0 1em 0 0;
|
|
overflow: hidden;
|
|
}
|
|
.breadcrumb > a {
|
|
flex: 0 4 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 0 -0.5em 0 -0.5em;
|
|
padding: 0;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
color: var(--breadcrumb-color);
|
|
padding: 0.3em 1.5em;
|
|
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
|
|
transition: all var(--breadcrumb-transtime);
|
|
}
|
|
.breadcrumb > a:first-child {
|
|
flex: 0 0 auto;
|
|
padding-left: 1.5em;
|
|
padding-right: 1.7em;
|
|
clip-path: none;
|
|
}
|
|
.breadcrumb > a:last-child {
|
|
clip-path: polygon(
|
|
0 0,
|
|
calc(100% - 1em) 0,
|
|
100% 50%,
|
|
calc(100% - 1em) 100%,
|
|
0 100%,
|
|
1em 50%,
|
|
0 0
|
|
);
|
|
}
|
|
.breadcrumb > a:only-child {
|
|
clip-path: polygon(
|
|
0 0,
|
|
calc(100% - 1em) 0,
|
|
100% 50%,
|
|
calc(100% - 1em) 100%,
|
|
0 100%,
|
|
0 0
|
|
);
|
|
}
|
|
.breadcrumb svg {
|
|
/* FIXME: Custom positioning to align it well; needs proper solution */
|
|
width: 1.3em;
|
|
height: 1.3em;
|
|
margin: -.5em;
|
|
fill: var(--breadcrumb-color);
|
|
transition: fill var(--breadcrumb-transtime);
|
|
}
|
|
.breadcrumb a:nth-child(odd) {
|
|
background: var(--breadcrumb-background-odd);
|
|
}
|
|
.breadcrumb a:nth-child(even) {
|
|
background: var(--breadcrumb-background-even);
|
|
}
|
|
.breadcrumb a:nth-child(odd):hover,
|
|
.breadcrumb a:focus:nth-child(odd) {
|
|
background: var(--breadcrumb-hover-background-odd);
|
|
}
|
|
.breadcrumb a:nth-child(even):hover,
|
|
.breadcrumb a:focus:nth-child(even) {
|
|
background: var(--breadcrumb-hover-background-even);
|
|
}
|
|
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
|
|
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
|
|
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
|
|
.breadcrumb a.current svg { fill: var(--accent-color) }
|
|
</style>
|