refactor(client): Refactor MkPageHeader #9869 (#9878)

* disable animation

* refactor(client): MkPageHeaderのタブをMkPageHeader.tabsに分離
animationをフォローするように

* update CHANGELOG.md

* remove unnecessary props
This commit is contained in:
tamaina 2023-02-11 16:04:45 +09:00 committed by GitHub
parent f74d9c7ed0
commit 9349f72227
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 239 additions and 193 deletions

View File

@ -8,6 +8,13 @@
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.x.x (unreleased)
### Improvements
- アニメーションを少なくする設定の時、MkPageHeaderのタブアニメーションを無効化
### Bugfixes
-
## 13.6.0 (2023/02/11) ## 13.6.0 (2023/02/11)

View File

@ -0,0 +1,218 @@
<template>
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
<div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
:class="$style.tabTitle">{{ t.title }}</div>
<Transition v-else @enter="enter" @after-enter="afterEnter" @leave="leave" @after-leave="afterLeave"
mode="in-out">
<div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div ref="tabHighlightEl"
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></div>
</div>
</template>
<script lang="ts">
export type Tab = {
key: string;
title: string;
icon?: string;
iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
} & {
iconOnly: true;
iccn: string;
};
</script>
<script lang="ts" setup>
import { onMounted, onUnmounted, watch, nextTick } from 'vue';
import { defaultStore } from '@/store';
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
rootEl?: HTMLElement;
}>(), {
tabs: () => ([] as Tab[]),
});
const emit = defineEmits<{
(ev: 'update:tab', key: string);
(ev: 'tabClick', key: string);
}>();
let el = $shallowRef<HTMLElement | null>(null);
const tabRefs: Record<string, HTMLElement | null> = {};
let tabHighlightEl = $shallowRef<HTMLElement | null>(null);
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
// mousedownonClick
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
emit('tabClick', t.key);
if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
}
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.style.width = rect.width + 'px';
tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px';
}
}
function onTabWheel(ev: WheelEvent) {
if (ev.deltaY !== 0 && ev.deltaX === 0) {
ev.preventDefault();
ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY,
behavior: 'smooth',
});
}
return false;
}
function enter(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.offsetWidth; // reflow
el.style.width = elementWidth + 'px';
setTimeout(renderTab, 70);
}
function afterEnter(el: HTMLElement) {
el.style.width = '';
nextTick(renderTab);
}
function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
el.offsetWidth; // reflow
el.style.width = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}
let ro2: ResizeObserver | null;
onMounted(() => {
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => renderTab());
}, {
immediate: true,
});
if (props.rootEl) {
ro2 = new ResizeObserver((entries, observer) => {
if (document.body.contains(el as HTMLElement)) {
nextTick(() => renderTab());
}
});
ro2.observe(props.rootEl);
}
});
onUnmounted(() => {
if (ro2) ro2.disconnect();
});
</script>
<style lang="scss" module>
.tabs {
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 0.8em;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
&.animate {
transition: opacity 0.2s ease;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon+.tabTitle {
margin-left: 8px;
}
.tabTitle {
overflow: hidden;
transition: width 0.15s ease-in-out;
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 999px;
transition: none;
pointer-events: none;
&.animate {
transition: width 0.15s ease, left 0.15s ease;
}
}
</style>

View File

@ -19,27 +19,7 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="!narrow || hideTitle" :class="$style.tabs" @wheel="onTabWheel"> <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
<div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly" :class="$style.tabTitle">{{ t.title }}</div>
<Transition
v-else
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
mode="in-out"
>
<div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
</template> </template>
<div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight"> <div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
<template v-for="action in actions"> <template v-for="action in actions">
@ -48,34 +28,19 @@
</div> </div>
</div> </div>
<div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> <div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div :class="$style.tabs" @wheel="onTabWheel"> <XTabs :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
<div :class="$style.tabsInner">
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
<i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i>
<span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span>
</button>
</div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; import { onMounted, onUnmounted, ref, inject } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { scrollToTop } from '@/scripts/scroll'; import { scrollToTop } from '@/scripts/scroll';
import { globalEvents } from '@/events'; import { globalEvents } from '@/events';
import { injectPageMetadata } from '@/scripts/page-metadata'; import { injectPageMetadata } from '@/scripts/page-metadata';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
import XTabs, { Tab } from './MkPageHeader.tabs.vue'
type Tab = {
key: string;
title: string;
icon?: string;
iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
};
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tabs?: Tab[]; tabs?: Tab[];
@ -102,8 +67,6 @@ const hideTitle = inject('shouldOmitHeaderTitle', false);
const thin_ = props.thin || inject('shouldHeaderThin', false); const thin_ = props.thin || inject('shouldHeaderThin', false);
let el = $shallowRef<HTMLElement | undefined>(undefined); let el = $shallowRef<HTMLElement | undefined>(undefined);
const tabRefs: Record<string, HTMLElement | null> = {};
let tabHighlightEl = $shallowRef<HTMLElement | null>(null);
const bg = ref<string | undefined>(undefined); const bg = ref<string | undefined>(undefined);
let narrow = $ref(false); let narrow = $ref(false);
const hasTabs = $computed(() => props.tabs.length > 0); const hasTabs = $computed(() => props.tabs.length > 0);
@ -128,25 +91,8 @@ function openAccountMenu(ev: MouseEvent) {
}, ev); }, ev);
} }
function onTabMousedown(tab: Tab, ev: MouseEvent): void { function onTabClick(): void {
// mousedownonClick top();
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
if (t.key === props.tab) {
top();
} else if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
} }
const calcBg = () => { const calcBg = () => {
@ -156,88 +102,26 @@ const calcBg = () => {
bg.value = tinyBg.toRgbString(); bg.value = tinyBg.toRgbString();
}; };
let ro1: ResizeObserver | null; let ro: ResizeObserver | null;
let ro2: ResizeObserver | null;
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.style.width = rect.width + 'px';
tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px';
}
}
function onTabWheel(ev: WheelEvent) {
if (ev.deltaY !== 0 && ev.deltaX === 0) {
ev.preventDefault();
ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY,
behavior: 'smooth',
});
}
return false;
}
function enter(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.offsetWidth; // reflow
el.style.width = elementWidth + 'px';
setTimeout(renderTab, 70);
}
function afterEnter(el: HTMLElement) {
el.style.width = '';
nextTick(renderTab);
}
function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
el.offsetWidth; // reflow
el.style.width = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}
onMounted(() => { onMounted(() => {
calcBg(); calcBg();
globalEvents.on('themeChanged', calcBg); globalEvents.on('themeChanged', calcBg);
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => renderTab());
}, {
immediate: true,
});
if (el && el.parentElement) { if (el && el.parentElement) {
narrow = el.parentElement.offsetWidth < 500; narrow = el.parentElement.offsetWidth < 500;
ro1 = new ResizeObserver((entries, observer) => { ro = new ResizeObserver((entries, observer) => {
if (el && el.parentElement && document.body.contains(el as HTMLElement)) { if (el && el.parentElement && document.body.contains(el as HTMLElement)) {
narrow = el.parentElement.offsetWidth < 500; narrow = el.parentElement.offsetWidth < 500;
} }
}); });
ro1.observe(el.parentElement as HTMLElement); ro.observe(el.parentElement as HTMLElement);
}
if (el) {
ro2 = new ResizeObserver((entries, observer) => {
if (document.body.contains(el as HTMLElement)) {
nextTick(() => renderTab());
}
});
ro2.observe(el);
} }
}); });
onUnmounted(() => { onUnmounted(() => {
globalEvents.off('themeChanged', calcBg); globalEvents.off('themeChanged', calcBg);
if (ro1) ro1.disconnect(); if (ro) ro.disconnect();
if (ro2) ro2.disconnect();
}); });
</script> </script>
@ -418,68 +302,4 @@ onUnmounted(() => {
} }
} }
} }
.tabs {
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 0.8em;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon + .tabTitle {
margin-left: 8px;
}
.tabTitle {
overflow: hidden;
transition: width 0.15s ease-in-out;
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 999px;
transition: width 0.15s ease, left 0.15s ease;
pointer-events: none;
}
</style> </style>

View File

@ -32,6 +32,7 @@ import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { $i } from '@/account'; import { $i } from '@/account';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
provide('shouldOmitHeaderTitle', true); provide('shouldOmitHeaderTitle', true);
@ -57,7 +58,7 @@ function queueUpdated(q: number): void {
} }
function top(): void { function top(): void {
scroll(rootEl, { top: 0 }); if (rootEl) scroll(rootEl, { top: 0 });
} }
async function chooseList(ev: MouseEvent): Promise<void> { async function chooseList(ev: MouseEvent): Promise<void> {
@ -150,7 +151,7 @@ const headerTabs = $computed(() => [{
title: i18n.ts.channel, title: i18n.ts.channel,
iconOnly: true, iconOnly: true,
onClick: chooseChannel, onClick: chooseChannel,
}]); }] as Tab[]);
const headerTabsWhenNotLogin = $computed(() => [ const headerTabsWhenNotLogin = $computed(() => [
...(isLocalTimelineAvailable ? [{ ...(isLocalTimelineAvailable ? [{
@ -165,7 +166,7 @@ const headerTabsWhenNotLogin = $computed(() => [
icon: 'ti ti-whirl', icon: 'ti ti-whirl',
iconOnly: true, iconOnly: true,
}] : []), }] : []),
]); ] as Tab[]);
definePageMetadata(computed(() => ({ definePageMetadata(computed(() => ({
title: i18n.ts.timeline, title: i18n.ts.timeline,