Skip to content

Commit e9e3f60

Browse files
authored
Merge pull request #831 from Sysvale/feature/floating-button
Feature/floating action button component
2 parents e0ea1a0 + 72f324e commit e9e3f60

File tree

6 files changed

+564
-1
lines changed

6 files changed

+564
-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.86.0",
3+
"version": "3.87.0",
44
"description": "A design system built by Sysvale, using storybook and Vue components",
55
"repository": {
66
"type": "git",
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
<template>
2+
<div class="floating-action-button__container">
3+
<template v-if="showActions || isExiting">
4+
<div
5+
v-for="action in actions"
6+
:key="action"
7+
class="floating-action-button__subitem-container"
8+
:class="{ 'exiting': isExiting }"
9+
@click="onSubItemClick(action)"
10+
>
11+
<div class="floating-action-button__subitem-label">
12+
{{ action?.label }}
13+
</div>
14+
15+
<div class="floating-action-button__subitem">
16+
<icon
17+
:name="action?.icon"
18+
height="32"
19+
width="32"
20+
/>
21+
</div>
22+
</div>
23+
</template>
24+
25+
<div
26+
class="floating-action-button__main-button"
27+
:class="`floating-action-button__main-button--${variant}`"
28+
@click="onMainButtonClick"
29+
>
30+
<transition
31+
name="icon-transition"
32+
mode="out-in"
33+
>
34+
<icon
35+
v-if="showActions"
36+
key="close-icon"
37+
name="x-outline"
38+
height="20"
39+
width="20"
40+
:style="{ '--rotation-direction': rotationDirection }"
41+
/>
42+
43+
<icon
44+
v-else
45+
key="main-icon"
46+
:name="icon"
47+
height="20"
48+
width="20"
49+
:style="{ '--rotation-direction': rotationDirection }"
50+
/>
51+
</transition>
52+
</div>
53+
</div>
54+
</template>
55+
56+
<script setup>
57+
import { ref, computed, watch } from 'vue';
58+
import Icon from '../components/Icon.vue';
59+
60+
const props = defineProps({
61+
/**
62+
* Define a variante de cor do botão. São 9 variantes implementadas: 'green', 'teal',
63+
* 'blue', 'indigo', 'violet', 'pink', 'red', 'orange', 'amber', 'gray' e 'dark'.
64+
*/
65+
variant: {
66+
type: String,
67+
default: 'green',
68+
},
69+
/**
70+
* Define o ícone do botão.
71+
*/
72+
icon: {
73+
type: String,
74+
default: 'plus-outline',
75+
},
76+
/**
77+
* Lista de ações do subMenu. Cada item deve conter os atributos `label` e `icon`.
78+
*/
79+
actions: {
80+
type: Array,
81+
default: () => [],
82+
validator: (value) => {
83+
return value.length <= 5;
84+
},
85+
},
86+
/**
87+
* Define o tamanho do botão. São 3 variantes implementadas: 'sm', 'md' e 'lg'.
88+
*/
89+
size: {
90+
type: String,
91+
default: 'md',
92+
}
93+
});
94+
95+
const emits = defineEmits(['main-button-click', 'action-click']);
96+
97+
const showActions = ref(false);
98+
const isExiting = ref(false);
99+
const rotationDirection = ref(-1);
100+
101+
const resolvedSize = computed(() => {
102+
switch (props.size) {
103+
case 'sm':
104+
return '44px';
105+
case 'md':
106+
return '56px';
107+
case 'lg':
108+
return '68px';
109+
default:
110+
return '56px';
111+
}
112+
});
113+
114+
const resolvedActionsMargin = computed(() => {
115+
switch (props.size) {
116+
case 'sm':
117+
return '4px';
118+
case 'md':
119+
return '10px';
120+
case 'lg':
121+
return '16px';
122+
default:
123+
return '10px';
124+
}
125+
});
126+
127+
const resolvedBorderRadius = computed(() => {
128+
switch (props.size) {
129+
case 'sm':
130+
return '12px';
131+
case 'md':
132+
return '16px';
133+
case 'lg':
134+
return '20px';
135+
default:
136+
return '16px';
137+
}
138+
})
139+
140+
const resolvedIconColor = computed(() => {
141+
if (props.variant === 'white' || props.variant === 'gray') {
142+
return '#3B4754';
143+
}
144+
145+
return '#fff';
146+
});
147+
148+
watch(showActions, (newVal) => {
149+
if (!newVal) {
150+
isExiting.value = true;
151+
setTimeout(() => {
152+
isExiting.value = false;
153+
}, 500);
154+
}
155+
});
156+
157+
function onMainButtonClick() {
158+
if (props.actions.length > 0) {
159+
rotationDirection.value = showActions.value ? -1 : 1;
160+
showActions.value = !showActions.value;
161+
return;
162+
}
163+
164+
emits('main-button-click');
165+
}
166+
167+
function onSubItemClick(action) {
168+
showActions.value = false;
169+
emits('action-click', action);
170+
}
171+
172+
</script>
173+
174+
<style lang="scss" scoped>
175+
@import '../assets/sass/tokens.scss';
176+
177+
.floating-action-button {
178+
179+
&__container {
180+
position: absolute;
181+
bottom: 0;
182+
right: 0;
183+
margin: 0 16px 16px 0;
184+
display: flex;
185+
flex-direction: column;
186+
align-items: end;
187+
gap: spacer(6);
188+
z-index: $z-index-tooltip;
189+
}
190+
191+
&__main-button {
192+
position: relative;
193+
border-radius: v-bind(resolvedBorderRadius);
194+
width: v-bind(resolvedSize);
195+
height: v-bind(resolvedSize);
196+
display: flex;
197+
align-items: center;
198+
justify-content: center;
199+
padding: pa(3);
200+
color: v-bind(resolvedIconColor);
201+
box-shadow: $shadow-md;
202+
cursor: pointer;
203+
transition: background-color 0.2s ease;
204+
205+
@include variantResolver using ($color-name, $shade-50, $shade-100, $shade-200, $shade-300, $base-color, $shade-500, $shade-600) {
206+
background-color: $base-color;
207+
}
208+
209+
&::before {
210+
content: "";
211+
position: absolute;
212+
top: 0;
213+
left: 0;
214+
width: 100%;
215+
height: 100%;
216+
background-color: rgba(0, 0, 0, 0.1);
217+
border-radius: $border-radius-medium;
218+
opacity: 0;
219+
transition: opacity 0.2s ease;
220+
}
221+
222+
&:active::before {
223+
opacity: 1;
224+
}
225+
}
226+
227+
&__subitem-container {
228+
position: relative;
229+
display: flex;
230+
align-items: center;
231+
gap: 6px;
232+
margin-right: v-bind(resolvedActionsMargin);
233+
z-index: $z-index-tooltip;
234+
animation: slide-in 0.3s ease-in-out forwards;
235+
236+
&.exiting {
237+
animation: slide-out 0.3s ease-in-out forwards;
238+
}
239+
}
240+
241+
@keyframes slide-in {
242+
0% {
243+
transform: translateY(30px);
244+
opacity: 0;
245+
}
246+
100% {
247+
transform: translateY(0);
248+
opacity: 1;
249+
}
250+
}
251+
252+
&__subitem-label {
253+
@include caption;
254+
font-weight: $font-weight-semibold;
255+
padding: pYX(1, 2);
256+
margin: mb(1);
257+
color: $n-0;
258+
background-color: rgba(black, 0.6);
259+
border-radius: $border-radius-lil;
260+
}
261+
262+
&__subitem {
263+
position: relative;
264+
border-radius: $border-radius-small;
265+
width: 36px;
266+
height: 36px;
267+
margin-top: -6px;
268+
display: flex;
269+
align-items: center;
270+
justify-content: center;
271+
padding: pa(2);
272+
background-color: $n-0;
273+
color: $n-700;
274+
box-shadow: $shadow-md;
275+
cursor: pointer;
276+
transition: background-color 0.2s ease;
277+
278+
&::before {
279+
content: "";
280+
position: absolute;
281+
top: 0;
282+
left: 0;
283+
width: 100%;
284+
height: 100%;
285+
background-color: rgba(0, 0, 0, 0.1);
286+
border-radius: $border-radius-small;
287+
opacity: 0;
288+
transition: opacity 0.2s ease;
289+
}
290+
291+
&:active::before {
292+
opacity: 1;
293+
}
294+
}
295+
}
296+
297+
.icon-transition-enter-active,
298+
.icon-transition-leave-active {
299+
transition: opacity 0.25s ease, rotate 0.3s ease;
300+
}
301+
302+
.icon-transition-enter-from {
303+
opacity: 0;
304+
rotate: 0deg;
305+
}
306+
307+
.icon-transition-enter-to {
308+
opacity: 1;
309+
rotate: 0deg;
310+
}
311+
312+
.icon-transition-leave-from {
313+
opacity: 1;
314+
rotate: 0deg;
315+
}
316+
317+
.icon-transition-leave-to {
318+
opacity: 0;
319+
rotate: calc(-170deg * var(--rotation-direction, 1));
320+
}
321+
322+
@keyframes slide-in {
323+
0% {
324+
opacity: 0;
325+
transform: translateY(30px);
326+
}
327+
100% {
328+
opacity: 1;
329+
transform: translateY(0);
330+
}
331+
}
332+
333+
@keyframes slide-out {
334+
0% {
335+
opacity: 1;
336+
transform: translateY(0);
337+
}
338+
100% {
339+
opacity: 0;
340+
transform: translateY(30px);
341+
}
342+
}
343+
344+
</style>

src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import FileViewer from './FileViewer.vue';
4040
import FilterSelect from './FilterSelect.vue';
4141
import FlatButton from './FlatButton.vue';
4242
import FloatingAssistant from './FloatingAssistant.vue';
43+
import FloatingActionButton from './FloatingActionButton.vue';
4344
import GaugeChart from './GaugeChart.vue';
4445
import Grid from './Grid.vue';
4546
import GridItem from './GridItem.vue';
@@ -157,6 +158,7 @@ export default {
157158
app.component('CdsFlatButton', FlatButton);
158159
app.component('CdsFlexbox', Flexbox);
159160
app.component('CdsFloatingAssistant', FloatingAssistant);
161+
app.component('CdsFloatingActionButton', FloatingActionButton);
160162
app.component('CdsGaugeChart', GaugeChart);
161163
app.component('CdsGrid', Grid);
162164
app.component('CdsGridItem', GridItem);

0 commit comments

Comments
 (0)