@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.
@@ -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
+ };