Skip to content

Commit e0ea1a0

Browse files
authored
Merge pull request #824 from Sysvale/feature/mobile-navbar
Feature/mobile navbar
2 parents 4c5c51e + b1a9975 commit e0ea1a0

File tree

6 files changed

+364
-1
lines changed

6 files changed

+364
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sysvale/cuida",
3-
"version": "3.85.0",
3+
"version": "3.86.0",
44
"description": "A design system built by Sysvale, using storybook and Vue components",
55
"repository": {
66
"type": "git",

src/components/MobileNavbar.vue

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<template>
2+
<div class="mobile-navbar">
3+
<div class="mobile-navbar__items">
4+
<router-link
5+
v-for="(item, index) in items"
6+
:key="item"
7+
class="mobile-navbar__item-link"
8+
:to="routerPushTo(item)"
9+
@click="onItemClick(item, index)"
10+
>
11+
<div
12+
class="mobile-navbar__item"
13+
:class="{
14+
'mobile-navbar__item--active': activeItem?.label === item.label,
15+
[`mobile-navbar__item--active--${variant}`]: activeItem?.label === item.label,
16+
}"
17+
>
18+
<icon
19+
height="20"
20+
width="20"
21+
:name="item.icon"
22+
/>
23+
24+
<div
25+
v-if="showLabel"
26+
class="mobile-navbar__item-text"
27+
>
28+
{{ item.label }}
29+
</div>
30+
</div>
31+
</router-link>
32+
33+
<div
34+
:class="computedClass"
35+
:style="indicatorStyle"
36+
/>
37+
</div>
38+
</div>
39+
</template>
40+
41+
<script setup>
42+
import { ref, computed } from 'vue';
43+
import Icon from '../components/Icon.vue';
44+
45+
const props = defineProps({
46+
/**
47+
* Define os itens de menu da navbar.
48+
*/
49+
items: {
50+
type: Array,
51+
required: true,
52+
validator: (value) => {
53+
return value.length > 0 && value.lenght <= 5 && value.every(item => item.route.path && item.route.name);
54+
},
55+
},
56+
/**
57+
* Define a variante de cor dos detalhes do componente. São 9 variantes implementadas: 'green', 'teal',
58+
* 'blue', 'indigo', 'violet', 'pink', 'red', 'orange', 'amber', 'gray' e 'dark'.
59+
*/
60+
variant: {
61+
type: String,
62+
default: 'green',
63+
},
64+
/**
65+
* Remove as labels dos itens da navbar.
66+
*/
67+
showLabel: {
68+
type: Boolean,
69+
default: false,
70+
},
71+
});
72+
73+
const emits = defineEmits(['item-click']);
74+
75+
const activeItem = ref(props.items[0]);
76+
const activeIndex = ref(0);
77+
78+
const indicatorStyle = computed(() => {
79+
const itemWidth = 100 / props.items.length;
80+
return {
81+
width: `${itemWidth}%`,
82+
transform: `translateX(${activeIndex.value * 100}%)`,
83+
};
84+
});
85+
86+
const computedClass = computed(() => {
87+
let stringona = '';
88+
89+
switch (activeIndex.value) {
90+
case 0:
91+
stringona += `mobile-navbar__indicator--first `;
92+
break;
93+
case props.items.length - 1:
94+
stringona += `mobile-navbar__indicator--last `;
95+
break;
96+
default:
97+
stringona += `mobile-navbar__indicator `;
98+
break;
99+
}
100+
101+
return stringona.concat(`mobile-navbar__indicator--${props.variant}`);
102+
});
103+
104+
function routerPushTo(item) {
105+
if (item.route.name) {
106+
return { name: item.route.name };
107+
}
108+
109+
return { path: item.route.path };
110+
}
111+
112+
function onItemClick(item, index) {
113+
activeItem.value = item;
114+
activeIndex.value = index;
115+
emits('item-click', item);
116+
}
117+
118+
</script>
119+
120+
<style lang="scss" scoped>
121+
@import '../assets/sass/tokens.scss';
122+
123+
.mobile-navbar {
124+
position: absolute;
125+
bottom: 0;
126+
left: 0;
127+
width: 100%;
128+
display: flex;
129+
align-items: center;
130+
justify-content: space-between;
131+
background-color: rgba(#fff, .85);
132+
backdrop-filter: blur(10px);
133+
box-shadow: 0px -1px 4px rgba(0, 0, 0, 0.05);
134+
position: relative;
135+
136+
&__items {
137+
display: flex;
138+
width: 100%;
139+
justify-content: space-between;
140+
align-items: center;
141+
position: relative;
142+
}
143+
144+
&__item {
145+
display: flex;
146+
flex-direction: column;
147+
justify-content: center;
148+
align-items: center;
149+
gap: spacer(1);
150+
flex: 1;
151+
min-width: 0;
152+
height: 100%;
153+
min-height: 50px;
154+
padding: pYX(2, 1);
155+
position: relative;
156+
color: $n-600;
157+
z-index: 1;
158+
159+
&--active {
160+
@include variantResolver using ($color-name, $shade-50, $shade-100, $shade-200, $shade-300, $base-color, $shade-500, $shade-600) {
161+
color: $shade-500;
162+
}
163+
}
164+
}
165+
166+
&__item-link {
167+
width: 100%;
168+
display: flex;
169+
flex-direction: column;
170+
gap: spacer(1);
171+
align-items: center;
172+
justify-content: center;
173+
cursor: pointer;
174+
}
175+
176+
&__item-text {
177+
font-size: 9.5px;
178+
font-weight: $font-weight-semibold;
179+
overflow: hidden;
180+
text-overflow: ellipsis;
181+
white-space: nowrap;
182+
text-align: center;
183+
width: 100%;
184+
}
185+
186+
&__indicator {
187+
position: absolute;
188+
top: 0;
189+
left: 0;
190+
height: 100%;
191+
transition: transform 0.3s ease;
192+
border-radius: 8px 8px 0 0;
193+
194+
&--first {
195+
@extend .mobile-navbar__indicator;
196+
border-radius: 0 8px 0 0;
197+
}
198+
199+
&--last {
200+
@extend .mobile-navbar__indicator;
201+
border-radius: 8px 0 0 0;
202+
}
203+
204+
@include variantResolver using ($color-name, $shade-50, $shade-100, $shade-200, $shade-300, $base-color, $shade-500, $shade-600) {
205+
background-color: $shade-50;
206+
color: $shade-500;
207+
}
208+
209+
&::after {
210+
content: '';
211+
position: absolute;
212+
bottom: 0;
213+
left: 0;
214+
width: 100%;
215+
height: 2px;
216+
background-color: currentColor;
217+
}
218+
}
219+
}
220+
</style>

src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import Link from './Link.vue';
5454
import List from './List.vue';
5555
import LoadingBar from './LoadingBar.vue';
5656
import LoadingIndicator from './LoadingIndicator.vue';
57+
import MobileNavbar from './MobileNavbar.vue';
5758
import MobileNavigation from './MobileNavigation.vue';
5859
import Modal from './Modal.vue';
5960
import Multiselect from './Multiselect.vue';
@@ -171,6 +172,7 @@ export default {
171172
app.component('CdsInlineDateInput', InlineDateInput);
172173
app.component('CdsLoadingBar', LoadingBar);
173174
app.component('CdsLoadingIndicator', LoadingIndicator);
175+
app.component('CdsMobileNavbar', MobileNavbar);
174176
app.component('CdsMobileNavigation', MobileNavigation);
175177
app.component('CdsModal', Modal);
176178
app.component('CdsMultiselect', Multiselect);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Meta, Story, Props, ArgsTable, Canvas, Preview } from '@storybook/addon-docs';
2+
import MobileNavbar from '../../components/MobileNavbar.vue';
3+
import { colorOptions } from '../../utils/constants/colors';
4+
5+
<Meta
6+
title="Componentes/Navegação/MobileNavbar"
7+
component={ MobileNavbar }
8+
argTypes={{
9+
variant: {
10+
control:{
11+
type: 'select',
12+
options: colorOptions,
13+
}
14+
},
15+
}}
16+
parameters={{
17+
viewMode: 'docs',
18+
previewTabs: { canvas: { hidden: true }},
19+
docs: {
20+
source: {
21+
language: 'html',
22+
format:true,
23+
code: /*html*/
24+
`<cds-mobile-navbar
25+
:items=[
26+
{ icon: 'home-outline', label: 'Início', route: { name: 'Google', path: 'www.google.com' } },
27+
{ icon: 'search-outline', label: 'Busca', route: { name: 'Google', path: 'www.google.com' } },
28+
{ icon: 'notification-bell-outline', label: 'Notificações', route: { name: 'Google', path: 'www.google.com' } },
29+
{ icon: 'user-outline', label: 'Perfil', route: { name: 'Google', path: 'www.google.com' } },
30+
],
31+
variant="green",
32+
show-label
33+
/>`
34+
},
35+
}
36+
}}
37+
/>
38+
39+
export const Template = (args) => ({
40+
components: { CdsMobileNavbar: MobileNavbar },
41+
setup() {
42+
return { args };
43+
},
44+
template: /*html*/ `
45+
<div style="position: relative">
46+
<img
47+
src="https://images.unsplash.com/photo-1705437917228-3f971a25de6a?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
48+
style="width: 100%; height: 100%;"
49+
/>
50+
<cds-mobile-navbar
51+
style="position: absolute; bottom: 0; left: 0; width: 100%;"
52+
v-bind="args"
53+
@item-click="console.info('item-click: ', $event)"
54+
/>
55+
</div>
56+
`,
57+
});
58+
59+
# MobileNavbar
60+
61+
### MobileNavbar é uma barra de navegação móvel localizada na parte inferior da tela.
62+
---
63+
<br />
64+
65+
## Quando usar:
66+
- Em aplicações móveis ou responsivas que exigem uma navegação simples e acessível.
67+
- Quando for necessário fornecer acesso rápido a seções principais do aplicativo.
68+
- Para substituir navegações complexas em telas pequenas, mantendo a usabilidade.
69+
70+
<br />
71+
72+
## Quando não usar:
73+
- Em telas grandes (desktop), onde uma navbar lateral ou superior é mais apropriada.
74+
- Quando o número de itens de navegação é muito grande (mais de 5 itens).
75+
76+
<br />
77+
78+
## Preview
79+
80+
<Canvas>
81+
<Story
82+
name="MobileNavbar"
83+
args={{
84+
items: [
85+
{ icon: 'home-outline', label: 'Início', route: { name: 'Google', path: 'www.google.com' } },
86+
{ icon: 'search-outline', label: 'Busca', route: { name: 'Google', path: 'www.google.com' } },
87+
{ icon: 'notification-bell-outline', label: 'Notificações', route: { name: 'Google', path: 'www.google.com' } },
88+
{ icon: 'user-outline', label: 'Perfil', route: { name: 'Google', path: 'www.google.com' } },
89+
],
90+
variant: 'green',
91+
showLabel: false,
92+
}}
93+
>
94+
{ Template.bind({}) }
95+
</Story>
96+
</Canvas>
97+
98+
<ArgsTable story="MobileNavbar" />

src/tests/MobileNavbar.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, test, expect } from 'vitest';
2+
import MobileNavbar from '../components/MobileNavbar.vue';
3+
import { mount } from '@vue/test-utils';
4+
5+
const mockedData = [
6+
{ icon: 'home-outline', label: 'Início', route: { name: 'Google', path: 'www.google.com' } },
7+
{ icon: 'search-outline', label: 'Busca', route: { name: 'Google', path: 'www.google.com' } },
8+
{ icon: 'notification-bell-outline', label: 'Notificações', route: { name: 'Google', path: 'www.google.com' } },
9+
{ icon: 'user-outline', label: 'Perfil', route: { name: 'Google', path: 'www.google.com' } },
10+
];
11+
12+
describe('MobileNavbar', () => {
13+
test('renders correctly', async () => {
14+
const wrapper = mount(MobileNavbar, {
15+
global: {
16+
stubs: {
17+
'cds-icon': true,
18+
'cds-avatar': true,
19+
'router-link': true,
20+
},
21+
},
22+
props: {
23+
items: mockedData,
24+
variant: 'blue',
25+
},
26+
});
27+
28+
expect(wrapper.html()).toMatchSnapshot();
29+
});
30+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`MobileNavbar > renders correctly 1`] = `
4+
"<div data-v-659a37c0="" class="mobile-navbar">
5+
<div data-v-659a37c0="" class="mobile-navbar__items">
6+
<router-link-stub data-v-659a37c0="" class="mobile-navbar__item-link" to="[object Object]"></router-link-stub>
7+
<router-link-stub data-v-659a37c0="" class="mobile-navbar__item-link" to="[object Object]"></router-link-stub>
8+
<router-link-stub data-v-659a37c0="" class="mobile-navbar__item-link" to="[object Object]"></router-link-stub>
9+
<router-link-stub data-v-659a37c0="" class="mobile-navbar__item-link" to="[object Object]"></router-link-stub>
10+
<div data-v-659a37c0="" class="mobile-navbar__indicator--first mobile-navbar__indicator--blue" style="width: 25%; transform: translateX(0%);"></div>
11+
</div>
12+
</div>"
13+
`;

0 commit comments

Comments
 (0)