Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add priority menu to tabs #4641

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 4 additions & 1 deletion web/src/components/atomic/Icon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<SvgIcon v-else-if="name === 'pause'" :path="mdiPause" size="1.3rem" />
<SvgIcon v-else-if="name === 'play'" :path="mdiPlay" size="1.3rem" />
<SvgIcon v-else-if="name === 'remove'" :path="mdiClose" size="1.3rem" />
<SvgIcon v-else-if="name === 'dots'" :path="mdiDotsVertical" size="1.3rem" />

<SvgIcon v-else-if="name === 'visibility-private'" :path="mdiLockOutline" size="1.3rem" />
<SvgIcon v-else-if="name === 'visibility-internal'" :path="mdiLockOpenOutline" size="1.3rem" />
Expand Down Expand Up @@ -96,6 +97,7 @@ import {
mdiClose,
mdiCloseCircleOutline,
mdiCog,
mdiDotsVertical,
mdiDownload,
mdiDownloadCircle,
mdiDownloadOff,
Expand Down Expand Up @@ -183,7 +185,8 @@ export type IconNames =
| 'error'
| 'remove'
| 'visibility-private'
| 'visibility-internal';
| 'visibility-internal'
| 'dots';

defineProps<{
name: IconNames;
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/layout/scaffold/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
</div>
</div>

<div v-if="enableTabs" class="flex md:items-center flex-col py-2 md:flex-row md:justify-between md:py-0">
<Tabs class="<md:order-2" />
<div v-if="$slots.headerActions" class="flex content-start md:justify-end">
<div v-if="enableTabs" class="flex items-center flex-row justify-between py-0">
<Tabs />
<div v-if="$slots.headerActions" class="flex content-start justify-end">
<slot name="tabActions" />
</div>
</div>
Expand Down
144 changes: 121 additions & 23 deletions web/src/components/layout/scaffold/Tabs.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,133 @@
<template>
<div class="flex flex-wrap mt-2 md:gap-4">
<router-link
v-for="tab in tabs"
:key="tab.title"
v-slot="{ isActive, isExactActive }"
:to="tab.to"
class="border-transparent w-full py-1 md:w-auto flex cursor-pointer md:border-b-2 text-wp-text-100 items-center"
:active-class="tab.matchChildren ? '!border-wp-text-100' : ''"
:exact-active-class="tab.matchChildren ? '' : '!border-wp-text-100'"
>
<Icon
v-if="isExactActive || (isActive && tab.matchChildren)"
name="chevron-right"
class="md:hidden flex-shrink-0"
/>
<Icon v-else name="blank" class="md:hidden" />
<span
class="flex gap-2 items-center md:justify-center flex-row py-1 px-2 w-full min-w-20 dark:hover:bg-wp-background-100 hover:bg-wp-background-200 rounded-md"
<div ref="containerRef" class="relative">
<!-- Main tabs container -->
<div ref="tabsRef" class="flex flex-wrap mt-2 gap-4">
<router-link
v-for="tab in visibleTabs"
:key="tab.title"
:to="tab.to"
class="border-transparent py-1 flex cursor-pointer border-b-2 text-wp-text-100 items-center"
:active-class="tab.matchChildren ? '!border-wp-text-100' : ''"
:exact-active-class="tab.matchChildren ? '' : '!border-wp-text-100'"
>
<Icon v-if="tab.icon" :name="tab.icon" :class="tab.iconClass" class="flex-shrink-0" />
<span>{{ tab.title }}</span>
<CountBadge v-if="tab.count" :value="tab.count" />
</span>
</router-link>
<span
class="flex gap-2 items-center justify-center flex-row py-1 px-2 w-full min-w-20 dark:hover:bg-wp-background-100 hover:bg-wp-background-200 rounded-md"
>
<Icon v-if="tab.icon" :name="tab.icon" :class="tab.iconClass" class="flex-shrink-0" />
<span>{{ tab.title }}</span>
<CountBadge v-if="tab.count" :value="tab.count" />
</span>
</router-link>

<!-- Overflow dropdown -->
<div v-if="hiddenTabs.length" class="relative border-transparent py-1 border-b-2">
<IconButton icon="dots" class="w-8 h-8" @click="toggleDropdown" />

<div
v-if="isDropdownOpen"
class="absolute mt-1 bg-wp-background-100 dark:bg-wp-background-200 border border-wp-background-400 rounded-md shadow-lg z-20 dark:hover:bg-wp-background-100 hover:bg-wp-background-200"
:class="[visibleTabs.length === 0 ? 'left-0' : 'right-0']"
>
<router-link
v-for="tab in hiddenTabs"
:key="tab.title"
:to="tab.to"
class="block w-full px-4 py-2 text-left whitespace-nowrap"
@click="isDropdownOpen = false"
>
{{ tab.title }}
</router-link>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';

import CountBadge from '~/components/atomic/CountBadge.vue';
import Icon from '~/components/atomic/Icon.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import { useTabsClient } from '~/compositions/useTabs';

const { tabs } = useTabsClient();
const containerRef = ref<HTMLElement | null>(null);
const tabsRef = ref<HTMLElement | null>(null);
const isDropdownOpen = ref(false);
const visibleCount = ref(tabs.value.length);

const visibleTabs = computed(() => tabs.value.slice(0, visibleCount.value));
const hiddenTabs = computed(() => tabs.value.slice(visibleCount.value));

const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value;
};

const closeDropdown = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!containerRef.value?.contains(target)) {
isDropdownOpen.value = false;
}
};

watch(isDropdownOpen, (isOpen) => {
if (isOpen) {
window.addEventListener('click', closeDropdown);
} else {
window.removeEventListener('click', closeDropdown);
}
});

const updateVisibleItems = () => {
if (!containerRef.value || !tabsRef.value) return;

visibleCount.value = tabs.value.length;

nextTick(() => {
const parentElement = containerRef.value!.parentElement;
const parentWidth = parentElement?.clientWidth || 0;
const otherElements = Array.from(parentElement?.children || []).filter((el) => el !== containerRef.value);
const otherElementsWidth = otherElements.reduce((sum, el) => sum + el.getBoundingClientRect().width, 0);
const availableWidth = parentWidth - otherElementsWidth;
const moreButtonWidth = 32; // This need to match the width of the IconButton (w-8)
const gapWidth = 16; // This need to match the gap between the tabs (gap-4)
let totalWidth = 0;

const items = Array.from(tabsRef.value!.children);

for (let i = 0; i < items.length; i++) {
const itemWidth = items[i].getBoundingClientRect().width;
totalWidth += itemWidth;
if (i > 0) totalWidth += gapWidth;

if (totalWidth > availableWidth - (moreButtonWidth + gapWidth)) {
visibleCount.value = i;
return;
}
}

visibleCount.value = tabs.value.length;
});
};

onMounted(() => {
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(updateVisibleItems);
});

if (containerRef.value!) {
resizeObserver.observe(containerRef.value);
}

window.addEventListener('resize', updateVisibleItems);

nextTick(updateVisibleItems);

onUnmounted(() => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateVisibleItems);
window.removeEventListener('click', closeDropdown);
});
});
</script>