From 85365da69e3b150003dc5324bbc971e3e82b2e33 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Wed, 22 Jun 2022 16:29:21 +0900
Subject: [PATCH] refactor(client): refactor header tab handling

---
 .../src/components/global/page-header.vue     | 89 +++++++++++++------
 packages/client/src/pages/about.vue           |  8 +-
 packages/client/src/pages/admin-file.vue      |  8 +-
 packages/client/src/pages/admin/_header_.vue  | 87 +++++++++++++-----
 packages/client/src/pages/admin/emojis.vue    |  8 +-
 packages/client/src/pages/channels.vue        | 11 +--
 packages/client/src/pages/explore.vue         | 11 +--
 packages/client/src/pages/instance-info.vue   | 11 +--
 packages/client/src/pages/notifications.vue   |  8 +-
 .../src/pages/page-editor/page-editor.vue     | 14 ++-
 packages/client/src/pages/pages.vue           | 11 +--
 packages/client/src/pages/timeline.vue        | 16 ++--
 packages/client/src/pages/user-info.vue       | 11 +--
 13 files changed, 170 insertions(+), 123 deletions(-)

diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue
index c01631c6a3..0ad7699834 100644
--- a/packages/client/src/components/global/page-header.vue
+++ b/packages/client/src/components/global/page-header.vue
@@ -12,16 +12,17 @@
 					{{ metadata.subtitle }}
 				</div>
 				<div v-if="narrow && hasTabs" class="subtitle activeTab">
-					{{ tabs.find(tab => tab.active)?.title }}
+					{{ tabs.find(tab => tab.key === props.tab)?.title }}
 					<i class="chevron fas fa-chevron-down"></i>
 				</div>
 			</div>
 		</div>
 		<div v-if="!narrow || hideTitle" class="tabs">
-			<button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
+			<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
 				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
 				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
 			</button>
+			<div ref="tabHighlightEl" class="highlight"></div>
 		</div>
 	</template>
 	<div class="buttons right">
@@ -33,22 +34,25 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, inject } from 'vue';
+import { computed, onMounted, onUnmounted, ref, inject, watch } from 'vue';
 import tinycolor from 'tinycolor2';
 import { popupMenu } from '@/os';
 import { scrollToTop } from '@/scripts/scroll';
 import { i18n } from '@/i18n';
 import { globalEvents } from '@/events';
-import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+
+type Tab = {
+	key?: string | null;
+	title: string;
+	icon?: string;
+	iconOnly?: boolean;
+	onClick?: (ev: MouseEvent) => void;
+};
 
 const props = defineProps<{
-	tabs?: {
-		title: string;
-		active: boolean;
-		icon?: string;
-		iconOnly?: boolean;
-		onClick: () => void;
-	}[];
+	tabs?: Tab[];
+	tab?: string;
 	actions?: {
 		text: string;
 		icon: string;
@@ -57,12 +61,18 @@ const props = defineProps<{
 	thin?: boolean;
 }>();
 
+const emit = defineEmits<{
+	(ev: 'update:tab', key: string);
+}>();
+
 const metadata = injectPageMetadata();
 
 const hideTitle = inject('shouldOmitHeaderTitle', false);
 const thin_ = props.thin || inject('shouldHeaderThin', false);
 
 const el = $ref<HTMLElement | null>(null);
+const tabRefs = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
 const bg = ref(null);
 let narrow = $ref(false);
 const height = ref(0);
@@ -80,7 +90,10 @@ const showTabsPopup = (ev: MouseEvent) => {
 	const menu = props.tabs.map(tab => ({
 		text: tab.title,
 		icon: tab.icon,
-		action: tab.onClick,
+		active: tab.key != null && tab.key === props.tab,
+		action: (ev) => {
+			onTabClick(tab, ev);
+		},
 	}));
 	popupMenu(menu, ev.currentTarget ?? ev.target);
 };
@@ -93,6 +106,20 @@ const onClick = () => {
 	scrollToTop(el, { behavior: 'smooth' });
 };
 
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+	if (tab.onClick) tab.onClick(ev);
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
 const calcBg = () => {
 	const rawBg = metadata?.bg || 'var(--bg)';
 	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
@@ -106,6 +133,20 @@ onMounted(() => {
 	calcBg();
 	globalEvents.on('themeChanged', calcBg);
 
+	watch(() => props.tab, () => {
+		const tabEl = tabRefs[props.tab];
+		if (tabEl && tabHighlightEl) {
+			// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+			// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+			const parentRect = tabEl.parentElement.getBoundingClientRect();
+			const rect = tabEl.getBoundingClientRect();
+			tabHighlightEl.style.width = rect.width + 'px';
+			tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+		}
+	}, {
+		immediate: true,
+	});
+
 	if (el && el.parentElement) {
 		narrow = el.parentElement.offsetWidth < 500;
 		ro = new ResizeObserver((entries, observer) => {
@@ -257,6 +298,7 @@ onUnmounted(() => {
 	}
 
 	> .tabs {
+		position: relative;
 		margin-left: 16px;
 		font-size: 0.8em;
 		overflow: auto;
@@ -276,25 +318,22 @@ onUnmounted(() => {
 
 			&.active {
 				opacity: 1;
-
-				&:after {
-					content: "";
-					display: block;
-					position: absolute;
-					bottom: 0;
-					left: 0;
-					right: 0;
-					margin: 0 auto;
-					width: 100%;
-					height: 3px;
-					background: var(--accent);
-				}
 			}
 
 			> .icon + .title {
 				margin-left: 8px;
 			}
 		}
+
+		> .highlight {
+			position: absolute;
+			bottom: 0;
+			height: 3px;
+			background: var(--accent);
+			border-radius: 999px;
+			transition: all 0.2s ease;
+			pointer-events: none;
+		}
 	}
 }
 </style>
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
index 20497c86fc..c0226bdb6c 100644
--- a/packages/client/src/pages/about.vue
+++ b/packages/client/src/pages/about.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
 		<div class="_formRoot">
 			<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
@@ -98,14 +98,12 @@ const initStats = () => os.api('stats', {
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'overview',
+	key: 'overview',
 	title: i18n.ts.overview,
-	onClick: () => { tab = 'overview'; },
 }, {
-	active: tab === 'charts',
+	key: 'charts',
 	title: i18n.ts.charts,
 	icon: 'fas fa-chart-bar',
-	onClick: () => { tab = 'charts'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue
index 7273ddce6a..7fb7cc1c87 100644
--- a/packages/client/src/pages/admin-file.vue
+++ b/packages/client/src/pages/admin-file.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer v-if="file" :content-max="500" :margin-min="16" :margin-max="32">
 		<div v-if="tab === 'overview'" class="cxqhhsmd _formRoot">
 			<a class="_formBlock thumbnail" :href="file.url" target="_blank">
@@ -103,15 +103,13 @@ const headerActions = $computed(() => [{
 }]);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'overview',
+	key: 'overview',
 	title: i18n.ts.overview,
 	icon: 'fas fa-info-circle',
-	onClick: () => { tab = 'overview'; },
 }, {
-	active: tab === 'raw',
+	key: 'raw',
 	title: 'Raw data',
 	icon: 'fas fa-code',
-	onClick: () => { tab = 'raw'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
index 9e11d065d9..59c5ce4f47 100644
--- a/packages/client/src/pages/admin/_header_.vue
+++ b/packages/client/src/pages/admin/_header_.vue
@@ -9,10 +9,11 @@
 			</div>
 		</div>
 		<div class="tabs">
-			<button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
+			<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
 				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
 				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
 			</button>
+			<div ref="tabHighlightEl" class="highlight"></div>
 		</div>
 	</template>
 	<div class="buttons right">
@@ -27,7 +28,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, inject } from 'vue';
+import { computed, onMounted, onUnmounted, ref, inject, watch } from 'vue';
 import tinycolor from 'tinycolor2';
 import { popupMenu } from '@/os';
 import { url } from '@/config';
@@ -35,16 +36,19 @@ import { scrollToTop } from '@/scripts/scroll';
 import MkButton from '@/components/ui/button.vue';
 import { i18n } from '@/i18n';
 import { globalEvents } from '@/events';
-import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+
+type Tab = {
+	key?: string | null;
+	title: string;
+	icon?: string;
+	iconOnly?: boolean;
+	onClick?: (ev: MouseEvent) => void;
+};
 
 const props = defineProps<{
-	tabs?: {
-		title: string;
-		active: boolean;
-		icon?: string;
-		iconOnly?: boolean;
-		onClick: () => void;
-	}[];
+	tabs?: Tab[];
+	tab?: string;
 	actions?: {
 		text: string;
 		icon: string;
@@ -54,9 +58,15 @@ const props = defineProps<{
 	thin?: boolean;
 }>();
 
+const emit = defineEmits<{
+	(ev: 'update:tab', key: string);
+}>();
+
 const metadata = injectPageMetadata();
 
 const el = ref<HTMLElement>(null);
+const tabRefs = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
 const bg = ref(null);
 const height = ref(0);
 const hasTabs = computed(() => {
@@ -71,7 +81,10 @@ const showTabsPopup = (ev: MouseEvent) => {
 	const menu = props.tabs.map(tab => ({
 		text: tab.title,
 		icon: tab.icon,
-		action: tab.onClick,
+		active: tab.key != null && tab.key === props.tab,
+		action: (ev) => {
+			onTabClick(tab, ev);
+		},
 	}));
 	popupMenu(menu, ev.currentTarget ?? ev.target);
 };
@@ -84,6 +97,20 @@ const onClick = () => {
 	scrollToTop(el.value, { behavior: 'smooth' });
 };
 
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+	if (tab.onClick) tab.onClick(ev);
+	if (tab.key) {
+		emit('update:tab', tab.key);
+	}
+}
+
 const calcBg = () => {
 	const rawBg = metadata?.bg || 'var(--bg)';
 	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
@@ -94,6 +121,20 @@ const calcBg = () => {
 onMounted(() => {
 	calcBg();
 	globalEvents.on('themeChanged', calcBg);
+
+	watch(() => props.tab, () => {
+		const tabEl = tabRefs[props.tab];
+		if (tabEl && tabHighlightEl) {
+			// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+			// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+			const parentRect = tabEl.parentElement.getBoundingClientRect();
+			const rect = tabEl.getBoundingClientRect();
+			tabHighlightEl.style.width = rect.width + 'px';
+			tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+		}
+	}, {
+		immediate: true,
+	});
 });
 
 onUnmounted(() => {
@@ -206,6 +247,7 @@ onUnmounted(() => {
 	}
 
 	> .tabs {
+		position: relative;
 		margin-left: 16px;
 		font-size: 0.8em;
 		overflow: auto;
@@ -225,25 +267,22 @@ onUnmounted(() => {
 
 			&.active {
 				opacity: 1;
-
-				&:after {
-					content: "";
-					display: block;
-					position: absolute;
-					bottom: 0;
-					left: 0;
-					right: 0;
-					margin: 0 auto;
-					width: 100%;
-					height: 3px;
-					background: var(--accent);
-				}
 			}
 
 			> .icon + .title {
 				margin-left: 8px;
 			}
 		}
+
+		> .highlight {
+			position: absolute;
+			bottom: 0;
+			height: 3px;
+			background: var(--accent);
+			border-radius: 999px;
+			transition: all 0.2s ease;
+			pointer-events: none;
+		}
 	}
 }
 </style>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
index 9d6b56dbc5..868472ac9c 100644
--- a/packages/client/src/pages/admin/emojis.vue
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -1,7 +1,7 @@
 <template>
 <div>
 	<MkStickyContainer>
-		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs" v-model:tab="tab"/></template>
 		<MkSpacer :content-max="900">
 			<div class="ogwlenmc">
 				<div v-if="tab === 'local'" class="local">
@@ -282,13 +282,11 @@ const headerActions = $computed(() => [{
 }]);
 
 const headerTabs = $computed(() => [{
-	active: tab.value === 'local',
+	key: 'local',
 	title: i18n.ts.local,
-	onClick: () => { tab.value = 'local'; },
 }, {
-	active: tab.value === 'remote',
+	key: 'remote',
 	title: i18n.ts.remote,
-	onClick: () => { tab.value = 'remote'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
index 89d23350f2..c48a64a1e7 100644
--- a/packages/client/src/pages/channels.vue
+++ b/packages/client/src/pages/channels.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="700">
 		<div v-if="tab === 'featured'" class="_content grwlizim featured">
 			<MkPagination v-slot="{items}" :pagination="featuredPagination">
@@ -59,20 +59,17 @@ const headerActions = $computed(() => [{
 }]);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'featured',
+	key: 'featured',
 	title: i18n.ts._channel.featured,
 	icon: 'fas fa-fire-alt',
-	onClick: () => { tab = 'featured'; },
 }, {
-	active: tab === 'following',
+	key: 'following',
 	title: i18n.ts._channel.following,
 	icon: 'fas fa-heart',
-	onClick: () => { tab = 'following'; },
 }, {
-	active: tab === 'owned',
+	key: 'owned',
 	title: i18n.ts._channel.owned,
 	icon: 'fas fa-edit',
-	onClick: () => { tab = 'owned'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
index 26e201cd99..cd0dba7817 100644
--- a/packages/client/src/pages/explore.vue
+++ b/packages/client/src/pages/explore.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="1200">
 		<div class="lznhrdub">
 			<div v-if="tab === 'local'">
@@ -178,17 +178,14 @@ os.api('stats').then(_stats => {
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'local',
+	key: 'local',
 	title: i18n.ts.local,
-	onClick: () => { tab = 'local'; },
 }, {
-	active: tab === 'remote',
+	key: 'remote',
 	title: i18n.ts.remote,
-	onClick: () => { tab = 'remote'; },
 }, {
-	active: tab === 'search',
+	key: 'search',
 	title: i18n.ts.search,
-	onClick: () => { tab = 'search'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
index cca04aca93..148eab6fcd 100644
--- a/packages/client/src/pages/instance-info.vue
+++ b/packages/client/src/pages/instance-info.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32">
 		<div v-if="tab === 'overview'" class="_formRoot">
 			<div class="fnfelxur">
@@ -183,20 +183,17 @@ const headerActions = $computed(() => [{
 }]);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'overview',
+	key: 'overview',
 	title: i18n.ts.overview,
 	icon: 'fas fa-info-circle',
-	onClick: () => { tab = 'overview'; },
 }, {
-	active: tab === 'chart',
+	key: 'chart',
 	title: i18n.ts.charts,
 	icon: 'fas fa-chart-simple',
-	onClick: () => { tab = 'chart'; },
 }, {
-	active: tab === 'raw',
+	key: 'raw',
 	title: 'Raw data',
 	icon: 'fas fa-code',
-	onClick: () => { tab = 'raw'; },
 }]);
 
 definePageMetadata({
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
index 3d1014b3cd..52cb298fa3 100644
--- a/packages/client/src/pages/notifications.vue
+++ b/packages/client/src/pages/notifications.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="800">
 		<div class="clupoqwt">
 			<XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
@@ -52,13 +52,11 @@ const headerActions = $computed(() => [{
 }]);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'all',
+	key: 'all',
 	title: i18n.ts.all,
-	onClick: () => { tab = 'all'; },
 }, {
-	active: tab === 'unread',
+	key: 'unread',
 	title: i18n.ts.unread,
-	onClick: () => { tab = 'unread'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
index c09d9af734..c38286c1d3 100644
--- a/packages/client/src/pages/page-editor/page-editor.vue
+++ b/packages/client/src/pages/page-editor/page-editor.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs" v-model:tab="tab"/></template>
 	<MkSpacer :content-max="700">
 		<div class="jqqmcavi">
 			<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
@@ -411,25 +411,21 @@ init();
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'settings',
+	key: 'settings',
 	title: i18n.ts._pages.pageSetting,
 	icon: 'fas fa-cog',
-	onClick: () => { tab = 'settings'; },
 }, {
-	active: tab === 'contents',
+	key: 'contents',
 	title: i18n.ts._pages.contents,
 	icon: 'fas fa-sticky-note',
-	onClick: () => { tab = 'contents'; },
 }, {
-	active: tab === 'variables',
+	key: 'variables',
 	title: i18n.ts._pages.variables,
 	icon: 'fas fa-magic',
-	onClick: () => { tab = 'variables'; },
 }, {
-	active: tab === 'script',
+	key: 'script',
 	title: i18n.ts.script,
 	icon: 'fas fa-code',
-	onClick: () => { tab = 'script'; },
 }]);
 
 definePageMetadata(computed(() => {
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
index 541c968ff4..16aeae2f56 100644
--- a/packages/client/src/pages/pages.vue
+++ b/packages/client/src/pages/pages.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="700">
 		<div v-if="tab === 'featured'" class="rknalgpo">
 			<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
@@ -61,20 +61,17 @@ const headerActions = $computed(() => [{
 }]);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'featured',
+	key: 'featured',
 	title: i18n.ts._pages.featured,
 	icon: 'fas fa-fire-alt',
-	onClick: () => { tab = 'featured'; },
 }, {
-	active: tab === 'my',
+	key: 'my',
 	title: i18n.ts._pages.my,
 	icon: 'fas fa-edit',
-	onClick: () => { tab = 'my'; },
 }, {
-	active: tab === 'liked',
+	key: 'liked',
 	title: i18n.ts._pages.liked,
 	icon: 'fas fa-heart',
-	onClick: () => { tab = 'liked'; },
 }]);
 
 definePageMetadata(computed(() => ({
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index 111451632c..004c29c56b 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="800">
 		<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
 			<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
@@ -45,7 +45,7 @@ const tlComponent = $ref<InstanceType<typeof XTimeline>>();
 const rootEl = $ref<HTMLElement>();
 
 let queue = $ref(0);
-const src = $computed(() => defaultStore.reactiveState.tl.value.src);
+const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) });
 
 watch ($$(src), () => queue = 0);
 
@@ -112,29 +112,25 @@ function focus(): void {
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => [{
-	active: src === 'home',
+	key: 'home',
 	title: i18n.ts._timelines.home,
 	icon: 'fas fa-home',
 	iconOnly: true,
-	onClick: () => { saveSrc('home'); },
 }, ...(isLocalTimelineAvailable ? [{
-	active: src === 'local',
+	key: 'local',
 	title: i18n.ts._timelines.local,
 	icon: 'fas fa-comments',
 	iconOnly: true,
-	onClick: () => { saveSrc('local'); },
 }, {
-	active: src === 'social',
+	key: 'social',
 	title: i18n.ts._timelines.social,
 	icon: 'fas fa-share-alt',
 	iconOnly: true,
-	onClick: () => { saveSrc('social'); },
 }] : []), ...(isGlobalTimelineAvailable ? [{
-	active: src === 'global',
+	key: 'global',
 	title: i18n.ts._timelines.global,
 	icon: 'fas fa-globe',
 	iconOnly: true,
-	onClick: () => { saveSrc('global'); },
 }] : []), {
 	icon: 'fas fa-list-ul',
 	title: i18n.ts.lists,
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 233a8857fc..67fc5ba7e3 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -1,6 +1,6 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
 		<FormSuspense :p="init">
 			<div v-if="tab === 'overview'" class="_formRoot">
@@ -234,20 +234,17 @@ watch(() => user, () => {
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => [{
-	active: tab === 'overview',
+	key: 'overview',
 	title: i18n.ts.overview,
 	icon: 'fas fa-info-circle',
-	onClick: () => { tab = 'overview'; },
 }, {
-	active: tab === 'chart',
+	key: 'chart',
 	title: i18n.ts.charts,
 	icon: 'fas fa-chart-simple',
-	onClick: () => { tab = 'chart'; },
 }, {
-	active: tab === 'raw',
+	key: 'raw',
 	title: 'Raw data',
 	icon: 'fas fa-code',
-	onClick: () => { tab = 'raw'; },
 }]);
 
 definePageMetadata(computed(() => ({