Skip to content

Commit 291d4f7

Browse files
authored
feat: improves navigation by implementing horizontal scrolling cards (#3498)
1 parent 2bf811d commit 291d4f7

File tree

10 files changed

+120
-22
lines changed

10 files changed

+120
-22
lines changed

assets/components.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ declare module 'vue' {
1818
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
1919
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
2020
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
21+
Carousel: typeof import('./components/common/Carousel.vue')['default']
22+
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
2123
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
2224
'Cil:circle': typeof import('~icons/cil/circle')['default']
2325
'Cil:columns': typeof import('~icons/cil/columns')['default']

assets/components/SideMenu.vue

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
<template>
2-
<div v-if="ready" data-testid="side-menu">
3-
<Toggle v-model="showSwarm" v-if="services.length > 0 || customGroups.length > 0">
4-
<div class="text-lg font-light">{{ $t("label.swarm-mode") }}</div>
5-
</Toggle>
6-
7-
<SlideTransition :slide-right="showSwarm">
8-
<template #left>
2+
<div v-if="ready" data-testid="side-menu" class="flex min-h-0 flex-col">
3+
<Carousel v-model="selectedCard" class="flex-1">
4+
<CarouselItem title="Hosts and Containers" id="host">
95
<HostMenu />
10-
</template>
11-
<template #right>
6+
</CarouselItem>
7+
<CarouselItem title="Services and Stacks" v-if="services.length > 0" id="swarm">
128
<SwarmMenu />
13-
</template>
14-
</SlideTransition>
9+
</CarouselItem>
10+
</Carousel>
1511
</div>
1612
<div role="status" class="flex animate-pulse flex-col gap-4" v-else>
1713
<div class="h-3 w-full rounded-full bg-base-content/50 opacity-50" v-for="_ in 9"></div>
@@ -24,17 +20,16 @@ const containerStore = useContainerStore();
2420
const { ready } = storeToRefs(containerStore);
2521
const route = useRoute();
2622
const swarmStore = useSwarmStore();
27-
const { services, customGroups } = storeToRefs(swarmStore);
28-
29-
const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
23+
const { services } = storeToRefs(swarmStore);
24+
const selectedCard = ref<"host" | "swarm">("host");
3025
3126
watch(
3227
route,
3328
() => {
3429
if (route.meta.swarmMode) {
35-
showSwarm.value = true;
30+
selectedCard.value = "swarm";
3631
} else if (route.meta.containerMode) {
37-
showSwarm.value = false;
32+
selectedCard.value = "host";
3833
}
3934
},
4035
{ immediate: true },

assets/components/SidePanel.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<aside class="fixed h-screen w-[inherit] overflow-auto p-3" data-testid="navigation">
2+
<aside class="fixed flex h-screen w-[inherit] flex-col gap-4 p-3" data-testid="navigation">
33
<h1>
44
<router-link :to="{ name: '/' }">
55
<LogoWithText class="logo h-16 w-40" />
@@ -11,7 +11,7 @@
1111
</h1>
1212

1313
<button
14-
class="input input-sm mt-4 inline-flex cursor-pointer items-center gap-2 font-light hover:border-primary"
14+
class="input input-sm inline-flex cursor-pointer items-center gap-2 self-start font-light hover:border-primary"
1515
@click="$emit('search')"
1616
:title="$t('tooltip.search')"
1717
data-testid="search"
@@ -21,7 +21,7 @@
2121
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
2222
</button>
2323

24-
<side-menu class="mt-4"></side-menu>
24+
<SideMenu class="mt-2 flex-1" />
2525
</aside>
2626
</template>
2727

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<template>
2+
<div class="flex min-h-0 flex-col gap-2">
3+
<div class="flex min-h-0 flex-1 flex-col overflow-auto overscroll-y-contain">
4+
<div
5+
ref="container"
6+
class="scrollbar-hide flex shrink-0 grow snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth"
7+
>
8+
<component v-for="(card, index) in providedCards" :key="index" :is="card" ref="cards" />
9+
</div>
10+
</div>
11+
<div class="flex flex-col gap-2">
12+
<h3 class="text-center text-sm font-thin">
13+
{{ cards?.[activeIndex].title }}
14+
</h3>
15+
<div class="flex flex-none justify-center gap-2" v-if="providedCards.length > 1">
16+
<button
17+
v-for="(c, index) in providedCards"
18+
:key="c.props?.id"
19+
@click="scrollToItem(index)"
20+
:class="[
21+
'size-2 rounded-full transition-all duration-700',
22+
activeIndex === index ? 'scale-125 bg-primary' : 'bg-base-content/50 hover:bg-base-content',
23+
]"
24+
:aria-label="c.props?.title"
25+
:title="c.props?.title"
26+
/>
27+
</div>
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script lang="ts" setup>
33+
import CarouselItem from "./CarouselItem.vue";
34+
const container = useTemplateRef<HTMLDivElement>("container");
35+
const activeIndex = ref(0);
36+
const activeId = defineModel<string>();
37+
const slots = defineSlots<{ default(): VNode[] }>();
38+
const providedCards = computed(() => slots.default().filter(({ type }) => type === CarouselItem));
39+
const cards = useTemplateRef<InstanceType<typeof CarouselItem>[]>("cards");
40+
41+
const scrollToItem = (index: number) => {
42+
cards.value?.[index].$el.scrollIntoView({
43+
behavior: "smooth",
44+
inline: "start",
45+
});
46+
};
47+
48+
const { pause, resume } = watchPausable(activeId, (v) => {
49+
if (activeId.value) {
50+
const index = cards.value?.map((c) => c.id).indexOf(activeId.value) ?? -1;
51+
if (index !== -1) {
52+
scrollToItem(index);
53+
}
54+
}
55+
});
56+
57+
useIntersectionObserver(
58+
cards as Ref<InstanceType<typeof CarouselItem>[]>,
59+
(entries) => {
60+
entries.forEach(({ isIntersecting, target }) => {
61+
if (isIntersecting) {
62+
const index = cards.value?.map((c) => c.$el).indexOf(target as HTMLDivElement) ?? -1;
63+
if (index !== -1) {
64+
pause();
65+
activeIndex.value = index;
66+
activeId.value = cards.value?.[index].id;
67+
nextTick(() => resume());
68+
}
69+
}
70+
});
71+
},
72+
{
73+
root: container,
74+
threshold: 0.5,
75+
},
76+
);
77+
</script>
78+
79+
<style scoped>
80+
.scrollbar-hide {
81+
scrollbar-width: none;
82+
-ms-overflow-style: none;
83+
}
84+
.scrollbar-hide::-webkit-scrollbar {
85+
display: none;
86+
}
87+
</style>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<template>
2+
<div class="w-full min-w-full flex-shrink-0 snap-start snap-always">
3+
<slot />
4+
</div>
5+
</template>
6+
7+
<script lang="ts" setup>
8+
const { id, title } = defineProps<{ id: string; title?: string }>();
9+
10+
defineExpose({
11+
id,
12+
title,
13+
});
14+
</script>
15+
16+
<style scoped></style>

assets/components/common/Toggle.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,5 @@
1010
</template>
1111

1212
<script lang="ts" setup>
13-
const { modelValue } = defineModels<{
14-
modelValue: boolean;
15-
}>();
13+
const modelValue = defineModel<boolean>();
1614
</script>
2.21 KB
Loading
2.19 KB
Loading
2.28 KB
Loading
2.18 KB
Loading

0 commit comments

Comments
 (0)