@xetwa/design-system 1.0.39 → 1.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/Buttons/WordChipIcon/WordChipIcon.stories.tsx +68 -0
- package/src/components/Buttons/WordChipIcon/WordChipIcon.tsx +186 -0
- package/src/components/Buttons/WordChipIcon/index.ts +1 -0
- package/src/components/Cards/BaseCard/BaseCard.tsx +17 -17
- package/src/components/Cards/BoardingPassCard/BoardingPassCard.tsx +25 -22
- package/src/components/Cards/ModuleCard/ModuleCard.stories.tsx +20 -1
- package/src/components/Cards/ModuleCard/ModuleCard.tsx +37 -14
- package/src/components/Feedback/BottomSheet/BottomSheet.tsx +124 -0
- package/src/components/Forms/CalendarPicker/CalendarPicker.stories.tsx +31 -0
- package/src/components/Forms/CalendarPicker/CalendarPicker.tsx +220 -0
- package/src/components/Forms/DaySelector/DaySelector.tsx +28 -0
- package/src/components/Forms/TimeSlot/TimeSlot.tsx +29 -0
- package/src/components/Navigation/TabBar/TabBar.stories.tsx +80 -0
- package/src/components/Navigation/TabBar/TabBar.tsx +245 -0
- package/src/index.ts +34 -30
- package/src/styles/theme.ts +107 -32
- package/src/utils/iconMap.ts +9 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { View, StyleSheet, Animated, Modal, Pressable } from 'react-native';
|
|
3
|
+
import { Typography } from '../../Typography/Typography';
|
|
4
|
+
import { theme } from '../../../styles/theme';
|
|
5
|
+
import { useResponsive } from '../../../hooks/useResponsive';
|
|
6
|
+
import { X } from 'lucide-react-native';
|
|
7
|
+
|
|
8
|
+
export interface BottomSheetProps {
|
|
9
|
+
/** Se o painel está visível */
|
|
10
|
+
isVisible: boolean;
|
|
11
|
+
/** Callback para fechar o painel */
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
/** Título do painel */
|
|
14
|
+
title?: string;
|
|
15
|
+
/** Conteúdo do painel */
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Componente BottomSheet
|
|
21
|
+
*
|
|
22
|
+
* Um painel que desliza desde a parte inferior do ecrã para mostrar
|
|
23
|
+
* conteúdos adicionais, como seletores, opções extras ou formulários.
|
|
24
|
+
*/
|
|
25
|
+
export const BottomSheet: React.FC<BottomSheetProps> = ({
|
|
26
|
+
isVisible,
|
|
27
|
+
onClose,
|
|
28
|
+
title,
|
|
29
|
+
children,
|
|
30
|
+
}) => {
|
|
31
|
+
const { scale } = useResponsive();
|
|
32
|
+
const [renderModal, setRenderModal] = useState(isVisible);
|
|
33
|
+
const translateY = useRef(new Animated.Value(1000)).current;
|
|
34
|
+
|
|
35
|
+
// Sincronizar montagem do Modal com a animação
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (isVisible) {
|
|
38
|
+
setRenderModal(true);
|
|
39
|
+
Animated.timing(translateY, {
|
|
40
|
+
toValue: 0,
|
|
41
|
+
duration: 300,
|
|
42
|
+
useNativeDriver: true,
|
|
43
|
+
easing: (value) => 1 - Math.pow(1 - value, 3), // ease-out
|
|
44
|
+
}).start();
|
|
45
|
+
} else if (renderModal) {
|
|
46
|
+
Animated.timing(translateY, {
|
|
47
|
+
toValue: 1000,
|
|
48
|
+
duration: 250,
|
|
49
|
+
useNativeDriver: true,
|
|
50
|
+
}).start(() => {
|
|
51
|
+
setRenderModal(false);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}, [isVisible, translateY, renderModal]);
|
|
55
|
+
|
|
56
|
+
const handleClose = () => {
|
|
57
|
+
onClose();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (!renderModal) return null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Modal visible={renderModal} transparent animationType="fade" onRequestClose={handleClose}>
|
|
64
|
+
<View style={styles.overlay}>
|
|
65
|
+
<Pressable style={styles.backdrop} onPress={handleClose} />
|
|
66
|
+
<Animated.View style={[styles.sheet, { transform: [{ translateY }] }]}>
|
|
67
|
+
|
|
68
|
+
<View style={[styles.header, { paddingVertical: scale(16), paddingHorizontal: scale(20) }]}>
|
|
69
|
+
{title ? (
|
|
70
|
+
<Typography variant="h2" style={styles.title}>{title}</Typography>
|
|
71
|
+
) : <View />}
|
|
72
|
+
|
|
73
|
+
<Pressable onPress={handleClose} style={({ pressed }) => [styles.closeBtn, pressed && { opacity: 0.5 }]}>
|
|
74
|
+
<X stroke="#9a8478" strokeWidth={2.5} size={scale(24)} />
|
|
75
|
+
</Pressable>
|
|
76
|
+
</View>
|
|
77
|
+
|
|
78
|
+
<View style={[styles.content, { paddingHorizontal: scale(20), paddingBottom: scale(32) }]}>
|
|
79
|
+
{children}
|
|
80
|
+
</View>
|
|
81
|
+
|
|
82
|
+
</Animated.View>
|
|
83
|
+
</View>
|
|
84
|
+
</Modal>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const styles = StyleSheet.create({
|
|
89
|
+
overlay: {
|
|
90
|
+
flex: 1,
|
|
91
|
+
justifyContent: 'flex-end',
|
|
92
|
+
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
93
|
+
},
|
|
94
|
+
backdrop: {
|
|
95
|
+
...StyleSheet.absoluteFillObject,
|
|
96
|
+
},
|
|
97
|
+
sheet: {
|
|
98
|
+
backgroundColor: theme.colors.white,
|
|
99
|
+
borderTopLeftRadius: theme.radius.xxl,
|
|
100
|
+
borderTopRightRadius: theme.radius.xxl,
|
|
101
|
+
width: '100%',
|
|
102
|
+
shadowColor: '#000',
|
|
103
|
+
shadowOffset: { width: 0, height: -2 },
|
|
104
|
+
shadowOpacity: 0.1,
|
|
105
|
+
shadowRadius: 10,
|
|
106
|
+
elevation: 10,
|
|
107
|
+
},
|
|
108
|
+
header: {
|
|
109
|
+
flexDirection: 'row',
|
|
110
|
+
justifyContent: 'space-between',
|
|
111
|
+
alignItems: 'center',
|
|
112
|
+
borderBottomWidth: 1,
|
|
113
|
+
borderBottomColor: theme.colors.brown[50], // #fbf6f0
|
|
114
|
+
},
|
|
115
|
+
title: {
|
|
116
|
+
color: theme.colors.brown[800],
|
|
117
|
+
},
|
|
118
|
+
closeBtn: {
|
|
119
|
+
padding: 4,
|
|
120
|
+
},
|
|
121
|
+
content: {
|
|
122
|
+
paddingTop: 16,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { CalendarPicker } from './CalendarPicker';
|
|
4
|
+
import { View } from 'react-native';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Forms/CalendarPicker',
|
|
8
|
+
component: CalendarPicker,
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<View style={{ padding: 20, backgroundColor: '#fbf6f0', flex: 1 }}>
|
|
12
|
+
<Story />
|
|
13
|
+
</View>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
} satisfies Meta<typeof CalendarPicker>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: () => {
|
|
23
|
+
const [date, setDate] = useState<string | Date>(new Date());
|
|
24
|
+
return (
|
|
25
|
+
<CalendarPicker
|
|
26
|
+
selectedDate={date}
|
|
27
|
+
onSelectDate={(str) => setDate(str)}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { View, StyleSheet, Pressable } from 'react-native';
|
|
3
|
+
import { Typography } from '../../Typography/Typography';
|
|
4
|
+
import { theme } from '../../../styles/theme';
|
|
5
|
+
import { useResponsive } from '../../../hooks/useResponsive';
|
|
6
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
|
|
7
|
+
|
|
8
|
+
export interface CalendarPickerProps {
|
|
9
|
+
/** Data atualmente selecionada (ex: '2024-03-24' ou Date object) */
|
|
10
|
+
selectedDate?: string | Date;
|
|
11
|
+
/** Callback quando uma data é escolhida */
|
|
12
|
+
onSelectDate: (dateString: string, date: Date) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WEEKDAYS = ['DOM', 'SEG', 'TER', 'QUA', 'QUI', 'SEX', 'SÁB'];
|
|
16
|
+
const MONTHS = [
|
|
17
|
+
'JANEIRO', 'FEVEREIRO', 'MARÇO', 'ABRIL', 'MAIO', 'JUNHO',
|
|
18
|
+
'JULHO', 'AGOSTO', 'SETEMBRO', 'OUTUBRO', 'NOVEMBRO', 'DEZEMBRO'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Componente CalendarPicker
|
|
23
|
+
* Permite a seleção de uma data num calendário nativo customizado.
|
|
24
|
+
*/
|
|
25
|
+
export const CalendarPicker: React.FC<CalendarPickerProps> = ({
|
|
26
|
+
selectedDate,
|
|
27
|
+
onSelectDate,
|
|
28
|
+
}) => {
|
|
29
|
+
const { scale } = useResponsive();
|
|
30
|
+
|
|
31
|
+
// Normalize selected date
|
|
32
|
+
const parsedSelectedDate = selectedDate
|
|
33
|
+
? (typeof selectedDate === 'string' ? new Date(selectedDate) : selectedDate)
|
|
34
|
+
: null;
|
|
35
|
+
|
|
36
|
+
// Estado para o mês e ano visualizados
|
|
37
|
+
const [currentViewDate, setCurrentViewDate] = useState(
|
|
38
|
+
parsedSelectedDate || new Date()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const viewMonth = currentViewDate.getMonth();
|
|
42
|
+
const viewYear = currentViewDate.getFullYear();
|
|
43
|
+
|
|
44
|
+
const handlePrevMonth = () => {
|
|
45
|
+
setCurrentViewDate(new Date(viewYear, viewMonth - 1, 1));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleNextMonth = () => {
|
|
49
|
+
setCurrentViewDate(new Date(viewYear, viewMonth + 1, 1));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Gerar dias do calendário
|
|
53
|
+
const daysInMonth = useMemo(() => {
|
|
54
|
+
const year = viewYear;
|
|
55
|
+
const month = viewMonth;
|
|
56
|
+
const date = new Date(year, month, 1);
|
|
57
|
+
const days: (Date | null)[] = [];
|
|
58
|
+
|
|
59
|
+
// Preencher dias vazios antes do dia 1 (para alinhar com o dia da semana correto)
|
|
60
|
+
const firstDayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc.
|
|
61
|
+
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
62
|
+
days.push(null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Preencher dias do mês
|
|
66
|
+
while (date.getMonth() === month) {
|
|
67
|
+
days.push(new Date(date));
|
|
68
|
+
date.setDate(date.getDate() + 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return days;
|
|
72
|
+
}, [viewYear, viewMonth]);
|
|
73
|
+
|
|
74
|
+
const isSameDay = (d1: Date, d2: Date) => {
|
|
75
|
+
return d1.getFullYear() === d2.getFullYear() &&
|
|
76
|
+
d1.getMonth() === d2.getMonth() &&
|
|
77
|
+
d1.getDate() === d2.getDate();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Função para garantir formato YYYY-MM-DD local sem problema de fuso horário
|
|
81
|
+
const formatDateString = (date: Date) => {
|
|
82
|
+
const y = date.getFullYear();
|
|
83
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
84
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
85
|
+
return `${y}-${m}-${d}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<View style={styles.container}>
|
|
90
|
+
{/* Header do Mês */}
|
|
91
|
+
<View style={styles.header}>
|
|
92
|
+
<Pressable onPress={handlePrevMonth} style={({ pressed }) => [styles.arrowBtn, pressed && { opacity: 0.5 }]}>
|
|
93
|
+
<ChevronLeft stroke={theme.colors.brown[800]} strokeWidth={2.5} size={scale(24)} />
|
|
94
|
+
</Pressable>
|
|
95
|
+
<Typography variant="h2" style={styles.monthText}>
|
|
96
|
+
{MONTHS[viewMonth]} {viewYear}
|
|
97
|
+
</Typography>
|
|
98
|
+
<Pressable onPress={handleNextMonth} style={({ pressed }) => [styles.arrowBtn, pressed && { opacity: 0.5 }]}>
|
|
99
|
+
<ChevronRight stroke={theme.colors.brown[800]} strokeWidth={2.5} size={scale(24)} />
|
|
100
|
+
</Pressable>
|
|
101
|
+
</View>
|
|
102
|
+
|
|
103
|
+
{/* Dias da Semana (D S T Q Q S S) */}
|
|
104
|
+
<View style={styles.weekdaysRow}>
|
|
105
|
+
{WEEKDAYS.map((day, index) => (
|
|
106
|
+
<View key={index} style={styles.weekdayCell}>
|
|
107
|
+
<Typography variant="label" style={styles.weekdayText}>
|
|
108
|
+
{day[0]}
|
|
109
|
+
</Typography>
|
|
110
|
+
</View>
|
|
111
|
+
))}
|
|
112
|
+
</View>
|
|
113
|
+
|
|
114
|
+
{/* Grelha de Dias */}
|
|
115
|
+
<View style={styles.daysGrid}>
|
|
116
|
+
{daysInMonth.map((date, index) => {
|
|
117
|
+
if (!date) {
|
|
118
|
+
return <View key={`empty-${index}`} style={styles.dayCell} />;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isSelected = parsedSelectedDate ? isSameDay(date, parsedSelectedDate) : false;
|
|
122
|
+
const isToday = isSameDay(date, new Date());
|
|
123
|
+
const isPast = date < new Date(new Date().setHours(0,0,0,0));
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Pressable
|
|
127
|
+
key={index}
|
|
128
|
+
disabled={isPast}
|
|
129
|
+
onPress={() => onSelectDate(formatDateString(date), date)}
|
|
130
|
+
style={({ pressed }) => [
|
|
131
|
+
styles.dayCell,
|
|
132
|
+
pressed && !isPast && { opacity: 0.7 },
|
|
133
|
+
isPast && { opacity: 0.3 }
|
|
134
|
+
]}
|
|
135
|
+
>
|
|
136
|
+
<View style={[
|
|
137
|
+
styles.dayCircle,
|
|
138
|
+
isSelected && styles.dayCircleSelected
|
|
139
|
+
]}>
|
|
140
|
+
<Typography
|
|
141
|
+
variant="bodyLg"
|
|
142
|
+
weight={isSelected ? 'bold' : 'normal'}
|
|
143
|
+
style={[
|
|
144
|
+
styles.dayText,
|
|
145
|
+
isSelected && styles.dayTextSelected,
|
|
146
|
+
isToday && !isSelected && styles.dayTextToday
|
|
147
|
+
]}
|
|
148
|
+
>
|
|
149
|
+
{date.getDate()}
|
|
150
|
+
</Typography>
|
|
151
|
+
</View>
|
|
152
|
+
</Pressable>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</View>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const styles = StyleSheet.create({
|
|
161
|
+
container: {
|
|
162
|
+
width: '100%',
|
|
163
|
+
},
|
|
164
|
+
header: {
|
|
165
|
+
flexDirection: 'row',
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
justifyContent: 'space-between',
|
|
168
|
+
marginBottom: 20,
|
|
169
|
+
paddingHorizontal: 8,
|
|
170
|
+
},
|
|
171
|
+
monthText: {
|
|
172
|
+
color: theme.colors.brown[800],
|
|
173
|
+
textTransform: 'uppercase',
|
|
174
|
+
},
|
|
175
|
+
arrowBtn: {
|
|
176
|
+
padding: 4,
|
|
177
|
+
},
|
|
178
|
+
weekdaysRow: {
|
|
179
|
+
flexDirection: 'row',
|
|
180
|
+
marginBottom: 8,
|
|
181
|
+
},
|
|
182
|
+
weekdayCell: {
|
|
183
|
+
flex: 1,
|
|
184
|
+
alignItems: 'center',
|
|
185
|
+
justifyContent: 'center',
|
|
186
|
+
},
|
|
187
|
+
weekdayText: {
|
|
188
|
+
color: theme.colors.brown[400],
|
|
189
|
+
},
|
|
190
|
+
daysGrid: {
|
|
191
|
+
flexDirection: 'row',
|
|
192
|
+
flexWrap: 'wrap',
|
|
193
|
+
},
|
|
194
|
+
dayCell: {
|
|
195
|
+
width: `${100 / 7}%`,
|
|
196
|
+
height: 44, // Altura fixa para garantir que o círculo interior fique centrado
|
|
197
|
+
alignItems: 'center',
|
|
198
|
+
justifyContent: 'center',
|
|
199
|
+
},
|
|
200
|
+
dayCircle: {
|
|
201
|
+
width: 36,
|
|
202
|
+
height: 36,
|
|
203
|
+
borderRadius: 18,
|
|
204
|
+
alignItems: 'center',
|
|
205
|
+
justifyContent: 'center',
|
|
206
|
+
},
|
|
207
|
+
dayCircleSelected: {
|
|
208
|
+
backgroundColor: theme.colors.primary,
|
|
209
|
+
},
|
|
210
|
+
dayText: {
|
|
211
|
+
color: theme.colors.brown[700],
|
|
212
|
+
},
|
|
213
|
+
dayTextSelected: {
|
|
214
|
+
color: theme.colors.white,
|
|
215
|
+
},
|
|
216
|
+
dayTextToday: {
|
|
217
|
+
color: theme.colors.primary,
|
|
218
|
+
fontWeight: 'bold',
|
|
219
|
+
},
|
|
220
|
+
});
|
|
@@ -4,6 +4,7 @@ import type { ViewStyle } from 'react-native';
|
|
|
4
4
|
import { theme } from '../../../styles/theme';
|
|
5
5
|
import { useResponsive } from '../../../hooks/useResponsive';
|
|
6
6
|
import { Typography } from '../../Typography/Typography';
|
|
7
|
+
import { Calendar } from 'lucide-react-native';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Interface para os dados de um dia disponível no DaySelector.
|
|
@@ -36,6 +37,8 @@ export interface DaySelectorProps {
|
|
|
36
37
|
onSelectDay: (id: string, date: Date) => void;
|
|
37
38
|
/** Estilo para o container exterior */
|
|
38
39
|
style?: ViewStyle | ViewStyle[];
|
|
40
|
+
/** Callback chamado quando o utilizador clica no ícone de calendário (para ver mais datas) */
|
|
41
|
+
onCalendarPress?: () => void;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export const DaySelector = ({
|
|
@@ -43,6 +46,7 @@ export const DaySelector = ({
|
|
|
43
46
|
selectedId,
|
|
44
47
|
onSelectDay,
|
|
45
48
|
style,
|
|
49
|
+
onCalendarPress,
|
|
46
50
|
}: DaySelectorProps) => {
|
|
47
51
|
const { scale, scaleText } = useResponsive();
|
|
48
52
|
|
|
@@ -110,6 +114,30 @@ export const DaySelector = ({
|
|
|
110
114
|
</Pressable>
|
|
111
115
|
);
|
|
112
116
|
})}
|
|
117
|
+
|
|
118
|
+
{onCalendarPress && (
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={onCalendarPress}
|
|
121
|
+
style={({ pressed }) => [
|
|
122
|
+
styles.item,
|
|
123
|
+
transitionStyle,
|
|
124
|
+
{
|
|
125
|
+
minWidth: scale(48),
|
|
126
|
+
paddingVertical: scale(8),
|
|
127
|
+
borderRadius: scale(13),
|
|
128
|
+
backgroundColor: '#fff',
|
|
129
|
+
borderWidth: scale(1.5),
|
|
130
|
+
borderColor: '#efe2d6',
|
|
131
|
+
borderStyle: 'dashed',
|
|
132
|
+
},
|
|
133
|
+
pressed && {
|
|
134
|
+
opacity: 0.7
|
|
135
|
+
}
|
|
136
|
+
]}
|
|
137
|
+
>
|
|
138
|
+
<Calendar stroke="#9a8478" strokeWidth={2} size={scale(20)} />
|
|
139
|
+
</Pressable>
|
|
140
|
+
)}
|
|
113
141
|
</ScrollView>
|
|
114
142
|
</View>
|
|
115
143
|
);
|
|
@@ -3,6 +3,7 @@ import type { ViewStyle } from 'react-native';
|
|
|
3
3
|
import { theme } from '../../../styles/theme';
|
|
4
4
|
import { useResponsive } from '../../../hooks/useResponsive';
|
|
5
5
|
import { Typography } from '../../Typography/Typography';
|
|
6
|
+
import { Plus } from 'lucide-react-native';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Interface para os dados de um horário disponível no TimeSlot.
|
|
@@ -31,6 +32,8 @@ export interface TimeSlotProps {
|
|
|
31
32
|
onSelectTime: (id: string, time: string) => void;
|
|
32
33
|
/** Estilo para o container exterior */
|
|
33
34
|
style?: ViewStyle | ViewStyle[];
|
|
35
|
+
/** Callback chamado quando o utilizador clica em "Ver mais" */
|
|
36
|
+
onShowMorePress?: () => void;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export const TimeSlot = ({
|
|
@@ -38,6 +41,7 @@ export const TimeSlot = ({
|
|
|
38
41
|
selectedId,
|
|
39
42
|
onSelectTime,
|
|
40
43
|
style,
|
|
44
|
+
onShowMorePress,
|
|
41
45
|
}: TimeSlotProps) => {
|
|
42
46
|
const { scale, scaleText } = useResponsive();
|
|
43
47
|
|
|
@@ -96,6 +100,31 @@ export const TimeSlot = ({
|
|
|
96
100
|
</Pressable>
|
|
97
101
|
);
|
|
98
102
|
})}
|
|
103
|
+
{onShowMorePress && (
|
|
104
|
+
<Pressable
|
|
105
|
+
onPress={onShowMorePress}
|
|
106
|
+
style={({ pressed }) => [
|
|
107
|
+
styles.item,
|
|
108
|
+
transitionStyle,
|
|
109
|
+
{
|
|
110
|
+
paddingVertical: scale(9),
|
|
111
|
+
paddingHorizontal: scale(14),
|
|
112
|
+
borderRadius: scale(11),
|
|
113
|
+
backgroundColor: '#fff',
|
|
114
|
+
borderWidth: scale(1.5),
|
|
115
|
+
borderColor: '#efe2d6',
|
|
116
|
+
borderStyle: 'dashed',
|
|
117
|
+
minHeight: scale(44),
|
|
118
|
+
minWidth: scale(44),
|
|
119
|
+
},
|
|
120
|
+
pressed && {
|
|
121
|
+
opacity: 0.7
|
|
122
|
+
}
|
|
123
|
+
]}
|
|
124
|
+
>
|
|
125
|
+
<Plus stroke="#9a8478" strokeWidth={2.5} size={scale(18)} />
|
|
126
|
+
</Pressable>
|
|
127
|
+
)}
|
|
99
128
|
</View>
|
|
100
129
|
</View>
|
|
101
130
|
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { TabBar } from './TabBar';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Navigation/TabBar',
|
|
8
|
+
component: TabBar,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'fullscreen',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component: 'TabBar component with 3 variants (A: Pill, B: Top Bar, C: Dot below) and notification badges.',
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
argTypes: {
|
|
19
|
+
variant: { control: 'radio', options: ['A', 'B', 'C'] },
|
|
20
|
+
safeAreaBottom: { control: 'number' },
|
|
21
|
+
activeItemId: { control: 'text' },
|
|
22
|
+
},
|
|
23
|
+
decorators: [
|
|
24
|
+
(Story) => (
|
|
25
|
+
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: '#f5f0eb' }}>
|
|
26
|
+
<Story />
|
|
27
|
+
</View>
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
} satisfies Meta<typeof TabBar>;
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof TabBar>;
|
|
34
|
+
|
|
35
|
+
const defaultItems = [
|
|
36
|
+
{ id: 'inicio', label: 'Início', icon: 'Home' },
|
|
37
|
+
{ id: 'percurso', label: 'Percurso', icon: 'Compass' },
|
|
38
|
+
{ id: 'aulas', label: 'Aulas', icon: 'GraduationCap' },
|
|
39
|
+
{ id: 'dossier', label: 'Dossier', hasBadge: true, icon: 'FolderOpen' },
|
|
40
|
+
{ id: 'perfil', label: 'Perfil', icon: 'User' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const VariantB: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
items: defaultItems,
|
|
46
|
+
activeItemId: 'inicio',
|
|
47
|
+
variant: 'B',
|
|
48
|
+
safeAreaBottom: 20,
|
|
49
|
+
},
|
|
50
|
+
render: function Render(args) {
|
|
51
|
+
const [activeId, setActiveId] = useState(args.activeItemId);
|
|
52
|
+
return <TabBar {...args} activeItemId={activeId} onItemPress={setActiveId} />;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const VariantA: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
items: defaultItems,
|
|
59
|
+
activeItemId: 'inicio',
|
|
60
|
+
variant: 'A',
|
|
61
|
+
safeAreaBottom: 20,
|
|
62
|
+
},
|
|
63
|
+
render: function Render(args) {
|
|
64
|
+
const [activeId, setActiveId] = useState(args.activeItemId);
|
|
65
|
+
return <TabBar {...args} activeItemId={activeId} onItemPress={setActiveId} />;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const VariantC: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
items: defaultItems,
|
|
72
|
+
activeItemId: 'inicio',
|
|
73
|
+
variant: 'C',
|
|
74
|
+
safeAreaBottom: 20,
|
|
75
|
+
},
|
|
76
|
+
render: function Render(args) {
|
|
77
|
+
const [activeId, setActiveId] = useState(args.activeItemId);
|
|
78
|
+
return <TabBar {...args} activeItemId={activeId} onItemPress={setActiveId} />;
|
|
79
|
+
}
|
|
80
|
+
};
|