diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c3ca8b38..995a1993 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -11,8 +11,8 @@ "lint": "eslint . && prettier --check .", "fix": "eslint . --fix && prettier --write .", "intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", - "cf-deploy": "pnpm run build && wrangler deploy --env preview", - "cf-dev": "pnpm run build && wrangler dev --env preview", + "cf-deploy": "pnpm run build && wrangler deploy --env staging", + "cf-dev": "pnpm run build && wrangler dev --env staging", "cf-typegen": "wrangler types" }, "devDependencies": { diff --git a/apps/frontend/src/components/ui/NavTabs.vue b/apps/frontend/src/components/ui/NavTabs.vue index 019bbb93..0d739d0b 100644 --- a/apps/frontend/src/components/ui/NavTabs.vue +++ b/apps/frontend/src/components/ui/NavTabs.vue @@ -2,78 +2,54 @@ @@ -89,6 +65,7 @@ interface Tab { shown?: boolean icon?: Component subpages?: string[] + onHover?: () => void } const props = withDefaults( @@ -109,124 +86,194 @@ const emit = defineEmits<{ tabClick: [index: number, tab: Tab] }>() +// DOM refs const scrollContainer = ref(null) +const tabLinkElements = ref() +// Slider pos state const sliderLeft = ref(4) const sliderTop = ref(4) const sliderRight = ref(4) const sliderBottom = ref(4) + +// active tab state const currentActiveIndex = ref(-1) const subpageSelected = ref(false) -const filteredLinks = computed(() => - props.links.filter((x) => (x.shown === undefined ? true : x.shown)), +// SSR state +const sliderReady = ref(false) // Slider is positioned and should be visible +const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint) + +const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true)) + +const sliderStyle = computed(() => ({ + left: `${sliderLeft.value}px`, + top: `${sliderTop.value}px`, + right: `${sliderRight.value}px`, + bottom: `${sliderBottom.value}px`, + opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0, +})) + +const isActiveAndNotSubpage = computed( + () => (index: number) => currentActiveIndex.value === index && !subpageSelected.value, ) -const sliderLeftPx = computed(() => `${sliderLeft.value}px`) -const sliderTopPx = computed(() => `${sliderTop.value}px`) -const sliderRightPx = computed(() => `${sliderRight.value}px`) -const sliderBottomPx = computed(() => `${sliderBottom.value}px`) -const tabLinkElements = ref() +function getSSRFallbackClasses(index: number) { + if (sliderReady.value) return {} + if (currentActiveIndex.value !== index) return {} -function pickLink() { - let index = -1 - subpageSelected.value = false + return { + 'rounded-full': true, + 'bg-button-bgSelected': !subpageSelected.value, + 'bg-button-bg': subpageSelected.value, + } +} +function getIconClasses(index: number) { + return { + 'text-button-textSelected': isActiveAndNotSubpage.value(index), + 'text-secondary': !isActiveAndNotSubpage.value(index), + } +} + +function getLabelClasses(index: number) { + return { + 'text-button-textSelected': isActiveAndNotSubpage.value(index), + 'text-contrast': !isActiveAndNotSubpage.value(index), + } +} + +function computeActiveIndex(): { index: number; isSubpage: boolean } { if (props.mode === 'local' && props.activeIndex !== undefined) { - index = Math.min(props.activeIndex, filteredLinks.value.length - 1) - } else { - for (let i = filteredLinks.value.length - 1; i >= 0; i--) { - const link = filteredLinks.value[i] - if (props.query) { - if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) { - index = i - break - } - } else if (decodeURIComponent(route.path) === link.href) { - index = i - break - } else if ( - decodeURIComponent(route.path).includes(link.href) || - (link.subpages && - link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage))) - ) { - index = i - subpageSelected.value = true - break - } + return { + index: Math.min(props.activeIndex, filteredLinks.value.length - 1), + isSubpage: false, } } - currentActiveIndex.value = index + for (let i = filteredLinks.value.length - 1; i >= 0; i--) { + const link = filteredLinks.value[i] + const decodedPath = decodeURIComponent(route.path) - if (currentActiveIndex.value !== -1) { - nextTick(() => startAnimation()) + // Query-based matching + if (props.query) { + const queryValue = route.query[props.query] + if (queryValue === link.href || (!queryValue && !link.href)) { + return { index: i, isSubpage: false } + } + continue + } + + // Exact path match + if (decodedPath === link.href) { + return { index: i, isSubpage: false } + } + + // Subpage match + const isSubpageMatch = + decodedPath.includes(link.href) || + link.subpages?.some((subpage) => decodedPath.includes(subpage)) + + if (isSubpageMatch) { + return { index: i, isSubpage: true } + } + } + + return { index: -1, isSubpage: false } +} + +function getTabElement(index: number): HTMLElement | null { + if (!tabLinkElements.value?.[index]) return null + + // In navigation mode, elements are NuxtLinks with $el property + // In local mode, elements are plain divs + const element = tabLinkElements.value[index] + return props.mode === 'navigation' ? (element as any).$el : element +} + +function positionSlider() { + const el = getTabElement(currentActiveIndex.value) + if (!el?.offsetParent) return + + const parent = el.offsetParent as HTMLElement + const newPosition = { + left: el.offsetLeft, + top: el.offsetTop, + right: parent.offsetWidth - el.offsetLeft - el.offsetWidth, + bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight, + } + + const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4 + + if (isInitialPosition) { + // Initial positioning: set position instantly, no animation + sliderLeft.value = newPosition.left + sliderRight.value = newPosition.right + sliderTop.value = newPosition.top + sliderBottom.value = newPosition.bottom + + sliderReady.value = true + + // enable transitions after slider is painted, so future changes animate + requestAnimationFrame(() => { + transitionsEnabled.value = true + }) + } else { + animateSliderTo(newPosition) + } +} + +function animateSliderTo(newPosition: { + left: number + top: number + right: number + bottom: number +}) { + const STAGGER_DELAY = 200 + + // Horizontal animation - lead with the direction of movement + if (newPosition.left < sliderLeft.value) { + sliderLeft.value = newPosition.left + setTimeout(() => (sliderRight.value = newPosition.right), STAGGER_DELAY) + } else { + sliderRight.value = newPosition.right + setTimeout(() => (sliderLeft.value = newPosition.left), STAGGER_DELAY) + } + + // Vertical animation - lead with the direction of movement + if (newPosition.top < sliderTop.value) { + sliderTop.value = newPosition.top + setTimeout(() => (sliderBottom.value = newPosition.bottom), STAGGER_DELAY) + } else { + sliderBottom.value = newPosition.bottom + setTimeout(() => (sliderTop.value = newPosition.top), STAGGER_DELAY) + } +} + +function updateActiveTab() { + const { index, isSubpage } = computeActiveIndex() + currentActiveIndex.value = index + subpageSelected.value = isSubpage + + if (index !== -1) { + nextTick(positionSlider) } else { sliderLeft.value = 0 sliderRight.value = 0 } } -function startAnimation() { - // In navigation mode, elements are NuxtLinks with $el property - // In local mode, elements are plain divs - const el = - props.mode === 'navigation' - ? tabLinkElements.value[currentActiveIndex.value]?.$el - : tabLinkElements.value[currentActiveIndex.value] +const initialActive = computeActiveIndex() +currentActiveIndex.value = initialActive.index +subpageSelected.value = initialActive.isSubpage - if (!el || !el.offsetParent) return - - const newValues = { - left: el.offsetLeft, - top: el.offsetTop, - right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth, - bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight, - } - - if (sliderLeft.value === 4 && sliderRight.value === 4) { - sliderLeft.value = newValues.left - sliderRight.value = newValues.right - sliderTop.value = newValues.top - sliderBottom.value = newValues.bottom - } else { - const delay = 200 - - if (newValues.left < sliderLeft.value) { - sliderLeft.value = newValues.left - setTimeout(() => { - sliderRight.value = newValues.right - }, delay) - } else { - sliderRight.value = newValues.right - setTimeout(() => { - sliderLeft.value = newValues.left - }, delay) - } - - if (newValues.top < sliderTop.value) { - sliderTop.value = newValues.top - setTimeout(() => { - sliderBottom.value = newValues.bottom - }, delay) - } else { - sliderBottom.value = newValues.bottom - setTimeout(() => { - sliderTop.value = newValues.top - }, delay) - } - } -} - -onMounted(() => { - pickLink() -}) +onMounted(updateActiveTab) watch( () => [route.path, route.query], () => { if (props.mode === 'navigation') { - pickLink() + updateActiveTab() } }, ) @@ -235,19 +282,12 @@ watch( () => props.activeIndex, () => { if (props.mode === 'local') { - pickLink() + updateActiveTab() } }, ) -watch( - () => props.links, - () => { - // Re-trigger animation when links change - pickLink() - }, - { deep: true }, -) +watch(() => props.links, updateActiveTab, { deep: true })