@xetwa/design-system 1.0.2
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/.github/workflows/publish.yml +30 -0
- package/.storybook/main.ts +17 -0
- package/.storybook/preview.tsx +65 -0
- package/eslint.config.js +22 -0
- package/index.html +13 -0
- package/package.json +55 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +184 -0
- package/src/App.tsx +122 -0
- package/src/BordasSombras.mdx +343 -0
- package/src/Colors.mdx +393 -0
- package/src/GrelhaLayout.mdx +375 -0
- package/src/Icons.mdx +42 -0
- package/src/MovimentoBreakpoints.mdx +379 -0
- package/src/Spacing.mdx +231 -0
- package/src/Typography.mdx +321 -0
- package/src/assets/Mascots/vava/estaticos/vava-acenar.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-acertou.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-celebrar.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-com-autocolantes.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-dormir.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-errou.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-feliz.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-neutra.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-original.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-pensar.svg +3 -0
- package/src/assets/Mascots/vava/estaticos/vava-surpresa.svg +3 -0
- package/src/assets/Mascots/yaya/avatar/yaya-avatar-falar.svg +7 -0
- package/src/assets/Mascots/yaya/avatar/yaya-avatar-feliz.svg +7 -0
- package/src/assets/Mascots/yaya/avatar/yaya-avatar-transparente.svg +6 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-acertou.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-celebrar.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-dormir.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-errou.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-falar.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-feliz.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-neutra.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-pensar.svg +13 -0
- package/src/assets/Mascots/yaya/estaticos/yaya-surpresa.svg +13 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/Buttons/BackButton/BackButton.stories.tsx +86 -0
- package/src/components/Buttons/BackButton/BackButton.tsx +107 -0
- package/src/components/Buttons/FabButton/FabButton.stories.tsx +74 -0
- package/src/components/Buttons/FabButton/FabButton.tsx +161 -0
- package/src/components/Buttons/IconButton/IconButton.stories.tsx +99 -0
- package/src/components/Buttons/IconButton/IconButton.tsx +189 -0
- package/src/components/Buttons/JourneyButton/JourneyButton.stories.tsx +80 -0
- package/src/components/Buttons/JourneyButton/JourneyButton.tsx +194 -0
- package/src/components/Buttons/MainButton/MainButton.stories.tsx +144 -0
- package/src/components/Buttons/MainButton/MainButton.tsx +258 -0
- package/src/components/Buttons/WordChip/WordChip.stories.tsx +84 -0
- package/src/components/Buttons/WordChip/WordChip.tsx +157 -0
- package/src/components/Buttons/WordChip/index.ts +1 -0
- package/src/components/Cards/AnswerOptionCard/AnswerOptionCard.stories.tsx +181 -0
- package/src/components/Cards/AnswerOptionCard/AnswerOptionCard.tsx +201 -0
- package/src/components/Cards/BaseCard/BaseCard.stories.tsx +148 -0
- package/src/components/Cards/BaseCard/BaseCard.tsx +181 -0
- package/src/components/Cards/BoardingPassCard/BoardingPassCard.stories.tsx +205 -0
- package/src/components/Cards/BoardingPassCard/BoardingPassCard.tsx +384 -0
- package/src/components/Cards/BoardingPassCard/FlightIcon.tsx +21 -0
- package/src/components/Cards/BoardingPassCard/FlightIcon.web.tsx +18 -0
- package/src/components/Cards/CountryCard/CountryCard.stories.tsx +97 -0
- package/src/components/Cards/CountryCard/CountryCard.tsx +185 -0
- package/src/components/Cards/LevelProgressCard/LevelProgressCard.stories.tsx +61 -0
- package/src/components/Cards/LevelProgressCard/LevelProgressCard.tsx +143 -0
- package/src/components/Cards/ModuleCard/ModuleCard.stories.tsx +108 -0
- package/src/components/Cards/ModuleCard/ModuleCard.tsx +304 -0
- package/src/components/Cards/ModuleCompletionCard/ModuleCompletionCard.stories.tsx +53 -0
- package/src/components/Cards/ModuleCompletionCard/ModuleCompletionCard.tsx +167 -0
- package/src/components/Cards/Passport/PassportCover.tsx +114 -0
- package/src/components/Cards/Passport/PassportIDPage.tsx +217 -0
- package/src/components/Cards/Passport/PassportStampPage.tsx +253 -0
- package/src/components/Cards/Passport/PassportViewer.stories.tsx +95 -0
- package/src/components/Cards/Passport/PassportViewer.tsx +328 -0
- package/src/components/Cards/PlanCard/PlanCard.stories.tsx +159 -0
- package/src/components/Cards/PlanCard/PlanCard.tsx +317 -0
- package/src/components/Cards/PricingCard/PricingCard.stories.tsx +83 -0
- package/src/components/Cards/PricingCard/PricingCard.tsx +144 -0
- package/src/components/Cards/SelectionCard/SelectionCard.stories.tsx +77 -0
- package/src/components/Cards/SelectionCard/SelectionCard.tsx +111 -0
- package/src/components/Cards/TeacherCard/TeacherCard.stories.tsx +126 -0
- package/src/components/Cards/TeacherCard/TeacherCard.tsx +236 -0
- package/src/components/Charts/WeeklyProgressChart/WeeklyProgressChart.stories.tsx +89 -0
- package/src/components/Charts/WeeklyProgressChart/WeeklyProgressChart.tsx +179 -0
- package/src/components/Charts/WeeklyProgressChart/index.ts +1 -0
- package/src/components/DataDisplay/Avatar/Avatar.stories.tsx +85 -0
- package/src/components/DataDisplay/Avatar/Avatar.tsx +118 -0
- package/src/components/DataDisplay/Avatar/index.ts +1 -0
- package/src/components/DataDisplay/Badge/Badge.stories.tsx +62 -0
- package/src/components/DataDisplay/Badge/Badge.tsx +146 -0
- package/src/components/DataDisplay/Flag/Flag.stories.tsx +98 -0
- package/src/components/DataDisplay/Flag/Flag.tsx +347 -0
- package/src/components/DataDisplay/Flag/Flag.web.tsx +334 -0
- package/src/components/DataDisplay/InstructionBubble/InstructionBubble.stories.tsx +80 -0
- package/src/components/DataDisplay/InstructionBubble/InstructionBubble.tsx +86 -0
- package/src/components/DataDisplay/LevelBadge/LevelBadge.stories.tsx +99 -0
- package/src/components/DataDisplay/LevelBadge/LevelBadge.tsx +118 -0
- package/src/components/DataDisplay/ProgressBar/ProgressBar.stories.tsx +95 -0
- package/src/components/DataDisplay/ProgressBar/ProgressBar.tsx +117 -0
- package/src/components/DataDisplay/StatBadge/StatBadge.stories.tsx +82 -0
- package/src/components/DataDisplay/StatBadge/StatBadge.tsx +135 -0
- package/src/components/DataDisplay/StatBadge/index.ts +1 -0
- package/src/components/DataDisplay/Tag/Tag.stories.tsx +63 -0
- package/src/components/DataDisplay/Tag/Tag.tsx +148 -0
- package/src/components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet.stories.tsx +74 -0
- package/src/components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet.tsx +200 -0
- package/src/components/Forms/Checkbox/Checkbox.stories.tsx +60 -0
- package/src/components/Forms/Checkbox/Checkbox.tsx +127 -0
- package/src/components/Forms/Checkbox/index.ts +1 -0
- package/src/components/Forms/DaySelector/DaySelector.stories.tsx +62 -0
- package/src/components/Forms/DaySelector/DaySelector.tsx +133 -0
- package/src/components/Forms/Radio/Radio.stories.tsx +61 -0
- package/src/components/Forms/Radio/Radio.tsx +141 -0
- package/src/components/Forms/Radio/index.ts +1 -0
- package/src/components/Forms/SegmentedToggle/SegmentedToggle.stories.tsx +67 -0
- package/src/components/Forms/SegmentedToggle/SegmentedToggle.tsx +150 -0
- package/src/components/Forms/SegmentedToggle/index.ts +1 -0
- package/src/components/Forms/Select/Select.stories.tsx +59 -0
- package/src/components/Forms/Select/Select.tsx +255 -0
- package/src/components/Forms/Select/index.ts +1 -0
- package/src/components/Forms/Switch/Switch.stories.tsx +57 -0
- package/src/components/Forms/Switch/Switch.tsx +120 -0
- package/src/components/Forms/Switch/index.ts +1 -0
- package/src/components/Forms/TimeSlot/TimeSlot.stories.tsx +62 -0
- package/src/components/Forms/TimeSlot/TimeSlot.tsx +116 -0
- package/src/components/Headers/CountryHeader/CountryHeader.stories.tsx +114 -0
- package/src/components/Headers/CountryHeader/CountryHeader.tsx +147 -0
- package/src/components/Headers/ProfileHeader/ProfileHeader.stories.tsx +141 -0
- package/src/components/Headers/ProfileHeader/ProfileHeader.tsx +233 -0
- package/src/components/Inputs/FillInTheBlank/FillInTheBlank.stories.tsx +333 -0
- package/src/components/Inputs/FillInTheBlank/FillInTheBlank.tsx +287 -0
- package/src/components/Inputs/FillInTheBlank/index.ts +1 -0
- package/src/components/Inputs/TextInput/TextInput.stories.tsx +103 -0
- package/src/components/Inputs/TextInput/TextInput.tsx +187 -0
- package/src/components/Mascots/Vava/Vava.stories.tsx +114 -0
- package/src/components/Mascots/Vava/Vava.tsx +276 -0
- package/src/components/Mascots/Yaya/Yaya.stories.tsx +98 -0
- package/src/components/Mascots/Yaya/Yaya.tsx +233 -0
- package/src/components/Navigation/BottomTabBar/BottomTabBar.stories.tsx +68 -0
- package/src/components/Navigation/BottomTabBar/BottomTabBar.tsx +85 -0
- package/src/components/Navigation/BottomTabBar/BottomTabBarItem.tsx +68 -0
- package/src/components/Navigation/BottomTabBar/index.ts +2 -0
- package/src/components/Navigation/PaginationDots/PaginationDots.stories.tsx +62 -0
- package/src/components/Navigation/PaginationDots/PaginationDots.tsx +81 -0
- package/src/components/Navigation/PaginationDots/index.ts +1 -0
- package/src/components/Navigation/ProgressMap/ProgressMap.stories.tsx +116 -0
- package/src/components/Navigation/ProgressMap/ProgressMap.tsx +827 -0
- package/src/components/Navigation/WorldProgressMap/WorldProgressMap.stories.tsx +169 -0
- package/src/components/Navigation/WorldProgressMap/WorldProgressMap.tsx +790 -0
- package/src/components/Typography/Typography.stories.tsx +127 -0
- package/src/components/Typography/Typography.tsx +53 -0
- package/src/hooks/useResponsive.ts +41 -0
- package/src/hooks/useTheme.ts +32 -0
- package/src/index.css +355 -0
- package/src/index.ts +28 -0
- package/src/main.tsx +10 -0
- package/src/mocks/codegenNativeComponent.ts +5 -0
- package/src/styles/countryThemes.ts +79 -0
- package/src/styles/theme.ts +297 -0
- package/src/utils/iconMap.ts +23 -0
- package/tsconfig.app.json +25 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +24 -0
- package/vite.config.ts +51 -0
- package/vitest.shims.d.ts +1 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, StyleSheet, Pressable, Modal, ScrollView, Platform, Animated } from 'react-native';
|
|
3
|
+
import type { ViewStyle } from 'react-native';
|
|
4
|
+
import { ChevronDown, ChevronUp, Check } from 'lucide-react-native';
|
|
5
|
+
import { theme } from '../../../styles/theme';
|
|
6
|
+
import { useResponsive } from '../../../hooks/useResponsive';
|
|
7
|
+
import { Typography } from '../../Typography/Typography';
|
|
8
|
+
|
|
9
|
+
export interface SelectOption {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Componente Select (Dropdown)
|
|
16
|
+
* No telemóvel renderiza um Bottom Sheet para escolha amigável de ecrã tátil.
|
|
17
|
+
*/
|
|
18
|
+
export interface SelectProps {
|
|
19
|
+
/** Valor atualmente selecionado (deve corresponder ao `value` de uma das opções). */
|
|
20
|
+
value?: string;
|
|
21
|
+
/** Array de opções a mostrar na lista. */
|
|
22
|
+
options: SelectOption[];
|
|
23
|
+
/** Texto provisório mostrado quando não há nada selecionado. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Função chamada com o novo valor quando o utilizador escolhe uma opção. */
|
|
26
|
+
onChange: (value: string) => void;
|
|
27
|
+
/** Desativa a abertura do select. */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/** Estilos adicionais para o botão que abre o select. */
|
|
30
|
+
style?: ViewStyle | ViewStyle[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Select = ({
|
|
34
|
+
value,
|
|
35
|
+
options,
|
|
36
|
+
placeholder = 'Selecione uma opção...',
|
|
37
|
+
onChange,
|
|
38
|
+
disabled = false,
|
|
39
|
+
style,
|
|
40
|
+
}: SelectProps) => {
|
|
41
|
+
const { scale, scaleText } = useResponsive();
|
|
42
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
43
|
+
|
|
44
|
+
const selectedOption = options.find((opt) => opt.value === value);
|
|
45
|
+
const displayLabel = selectedOption ? selectedOption.label : placeholder;
|
|
46
|
+
|
|
47
|
+
const slideAnim = React.useRef(new Animated.Value(0)).current;
|
|
48
|
+
|
|
49
|
+
const handlePress = () => {
|
|
50
|
+
if (!disabled) {
|
|
51
|
+
setIsOpen(true);
|
|
52
|
+
Animated.timing(slideAnim, {
|
|
53
|
+
toValue: 1,
|
|
54
|
+
duration: 300,
|
|
55
|
+
useNativeDriver: false, // backgroundColor needs false, or we just animate opacity
|
|
56
|
+
}).start();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const closeMenu = (callback?: () => void) => {
|
|
61
|
+
Animated.timing(slideAnim, {
|
|
62
|
+
toValue: 0,
|
|
63
|
+
duration: 250,
|
|
64
|
+
useNativeDriver: false,
|
|
65
|
+
}).start(() => {
|
|
66
|
+
setIsOpen(false);
|
|
67
|
+
if (callback) callback();
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleSelect = (val: string) => {
|
|
72
|
+
closeMenu(() => onChange(val));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Cores Dinâmicas
|
|
76
|
+
let borderColor = theme.colors.brown[100]; // #efe2d6
|
|
77
|
+
let backgroundColor = theme.colors.white;
|
|
78
|
+
let textColor = value ? theme.colors.brown[800] : theme.colors.brown[400];
|
|
79
|
+
let chevronColor = theme.colors.brown[400]; // #9a8478
|
|
80
|
+
|
|
81
|
+
if (disabled) {
|
|
82
|
+
backgroundColor = '#fdf3ea';
|
|
83
|
+
textColor = theme.colors.brown[400];
|
|
84
|
+
chevronColor = '#c3b1a4';
|
|
85
|
+
} else if (isOpen) {
|
|
86
|
+
borderColor = theme.colors.primary; // #f2974b
|
|
87
|
+
chevronColor = theme.colors.primary;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ChevronIcon = isOpen ? ChevronUp : ChevronDown;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Pressable
|
|
95
|
+
onPress={handlePress}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
style={[
|
|
98
|
+
styles.trigger,
|
|
99
|
+
{
|
|
100
|
+
height: scale(50),
|
|
101
|
+
paddingHorizontal: scale(16),
|
|
102
|
+
borderRadius: scale(14),
|
|
103
|
+
borderWidth: scale(2),
|
|
104
|
+
borderColor,
|
|
105
|
+
backgroundColor,
|
|
106
|
+
maxWidth: 400,
|
|
107
|
+
width: '100%',
|
|
108
|
+
},
|
|
109
|
+
// Aplicação simples de sombra quando aberto (em web seria outline)
|
|
110
|
+
isOpen && Platform.OS === 'web' && { outlineStyle: 'solid', outlineColor: 'rgba(242,151,75,0.2)', outlineWidth: 3 },
|
|
111
|
+
style
|
|
112
|
+
]}
|
|
113
|
+
accessibilityRole="button"
|
|
114
|
+
accessibilityState={{ expanded: isOpen, disabled }}
|
|
115
|
+
>
|
|
116
|
+
<Typography
|
|
117
|
+
style={[
|
|
118
|
+
styles.triggerText,
|
|
119
|
+
{
|
|
120
|
+
fontSize: scaleText(15),
|
|
121
|
+
color: textColor,
|
|
122
|
+
}
|
|
123
|
+
]}
|
|
124
|
+
numberOfLines={1}
|
|
125
|
+
>
|
|
126
|
+
{displayLabel}
|
|
127
|
+
</Typography>
|
|
128
|
+
<ChevronIcon stroke={chevronColor} size={scale(20)} strokeWidth={2} />
|
|
129
|
+
</Pressable>
|
|
130
|
+
|
|
131
|
+
<Modal
|
|
132
|
+
visible={isOpen}
|
|
133
|
+
transparent={true}
|
|
134
|
+
animationType="none"
|
|
135
|
+
onRequestClose={() => closeMenu()}
|
|
136
|
+
>
|
|
137
|
+
<View style={styles.modalContainer}>
|
|
138
|
+
<Animated.View
|
|
139
|
+
style={[
|
|
140
|
+
styles.modalOverlay,
|
|
141
|
+
{
|
|
142
|
+
opacity: slideAnim,
|
|
143
|
+
}
|
|
144
|
+
]}
|
|
145
|
+
>
|
|
146
|
+
<Pressable style={styles.modalDismiss} onPress={() => closeMenu()} />
|
|
147
|
+
</Animated.View>
|
|
148
|
+
|
|
149
|
+
<Animated.View
|
|
150
|
+
style={[
|
|
151
|
+
styles.bottomSheet,
|
|
152
|
+
{
|
|
153
|
+
borderTopLeftRadius: scale(24),
|
|
154
|
+
borderTopRightRadius: scale(24),
|
|
155
|
+
paddingBottom: scale(32), // Safe area placeholder
|
|
156
|
+
maxHeight: '70%',
|
|
157
|
+
transform: [
|
|
158
|
+
{
|
|
159
|
+
translateY: slideAnim.interpolate({
|
|
160
|
+
inputRange: [0, 1],
|
|
161
|
+
outputRange: [600, 0], // slide in from bottom
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
]}
|
|
167
|
+
>
|
|
168
|
+
<View style={[styles.handleContainer, { paddingVertical: scale(12) }]}>
|
|
169
|
+
<View style={[styles.handle, { width: scale(40), height: scale(4), borderRadius: scale(2) }]} />
|
|
170
|
+
</View>
|
|
171
|
+
|
|
172
|
+
<ScrollView style={styles.list}>
|
|
173
|
+
{options.map((option) => {
|
|
174
|
+
const isSelected = option.value === value;
|
|
175
|
+
return (
|
|
176
|
+
<Pressable
|
|
177
|
+
key={option.value}
|
|
178
|
+
style={({ pressed }) => [
|
|
179
|
+
styles.item,
|
|
180
|
+
{
|
|
181
|
+
paddingVertical: scale(12),
|
|
182
|
+
paddingHorizontal: scale(16),
|
|
183
|
+
backgroundColor: isSelected || pressed ? '#fef6ee' : theme.colors.white,
|
|
184
|
+
}
|
|
185
|
+
]}
|
|
186
|
+
onPress={() => handleSelect(option.value)}
|
|
187
|
+
>
|
|
188
|
+
<Typography
|
|
189
|
+
style={[
|
|
190
|
+
styles.itemText,
|
|
191
|
+
{
|
|
192
|
+
fontSize: scaleText(15),
|
|
193
|
+
color: isSelected ? theme.colors.primary : theme.colors.brown[800],
|
|
194
|
+
}
|
|
195
|
+
]}
|
|
196
|
+
>
|
|
197
|
+
{option.label}
|
|
198
|
+
</Typography>
|
|
199
|
+
{isSelected && (
|
|
200
|
+
<Check stroke={theme.colors.primary} size={scale(16)} strokeWidth={3} />
|
|
201
|
+
)}
|
|
202
|
+
</Pressable>
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</ScrollView>
|
|
206
|
+
</Animated.View>
|
|
207
|
+
</View>
|
|
208
|
+
</Modal>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const styles = StyleSheet.create({
|
|
214
|
+
trigger: {
|
|
215
|
+
flexDirection: 'row',
|
|
216
|
+
alignItems: 'center',
|
|
217
|
+
justifyContent: 'space-between',
|
|
218
|
+
},
|
|
219
|
+
triggerText: {
|
|
220
|
+
|
|
221
|
+
flex: 1,
|
|
222
|
+
marginRight: 8,
|
|
223
|
+
},
|
|
224
|
+
modalContainer: {
|
|
225
|
+
flex: 1,
|
|
226
|
+
justifyContent: 'flex-end',
|
|
227
|
+
},
|
|
228
|
+
modalOverlay: {
|
|
229
|
+
...StyleSheet.absoluteFill,
|
|
230
|
+
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
231
|
+
},
|
|
232
|
+
modalDismiss: {
|
|
233
|
+
flex: 1,
|
|
234
|
+
},
|
|
235
|
+
bottomSheet: {
|
|
236
|
+
backgroundColor: theme.colors.white,
|
|
237
|
+
},
|
|
238
|
+
handleContainer: {
|
|
239
|
+
alignItems: 'center',
|
|
240
|
+
},
|
|
241
|
+
handle: {
|
|
242
|
+
backgroundColor: theme.colors.brown[100],
|
|
243
|
+
},
|
|
244
|
+
list: {
|
|
245
|
+
// ScrollView flex by default
|
|
246
|
+
},
|
|
247
|
+
item: {
|
|
248
|
+
flexDirection: 'row',
|
|
249
|
+
alignItems: 'center',
|
|
250
|
+
justifyContent: 'space-between',
|
|
251
|
+
},
|
|
252
|
+
itemText: {
|
|
253
|
+
|
|
254
|
+
},
|
|
255
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Select';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// eslint-disable-next-line storybook/no-renderer-packages
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { View } from 'react-native';
|
|
5
|
+
import { Switch } from './Switch';
|
|
6
|
+
import { theme } from '../../../styles/theme';
|
|
7
|
+
import { Typography } from '../../Typography/Typography';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'Components/Forms/Switch',
|
|
11
|
+
component: Switch,
|
|
12
|
+
parameters: {
|
|
13
|
+
layout: 'padded',
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component: 'Um **Switch** (Pill switch) funciona como um interruptor para alternar entre ligado (ON) e desligado (OFF). \n\nUsa este componente para opções onde o estado entra em vigor imediatamente após a interação.\n\n### Exemplo de Uso\n```tsx\nconst [notificationsEnabled, setNotificationsEnabled] = useState(true);\n\nreturn (\n <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>\n <Typography>Notificações</Typography>\n <Switch \n value={notificationsEnabled}\n onValueChange={setNotificationsEnabled}\n />\n </View>\n)\n```',
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
tags: ['autodocs'],
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<View style={{ padding: 40, backgroundColor: theme.colors.cream, alignItems: 'flex-start' }}>
|
|
24
|
+
<Story />
|
|
25
|
+
</View>
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
} satisfies Meta<any>;
|
|
29
|
+
|
|
30
|
+
export default meta;
|
|
31
|
+
type Story = StoryObj<any>;
|
|
32
|
+
|
|
33
|
+
const InteractiveSwitch = ({ initialValue = false, disabled = false }) => {
|
|
34
|
+
const [value, setValue] = useState(initialValue);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
|
38
|
+
<Switch value={value} onValueChange={setValue} disabled={disabled} />
|
|
39
|
+
<Typography style={{ fontFamily: theme.fonts.title, fontWeight: '700', color: theme.colors.brown[800], fontSize: 14 }}>
|
|
40
|
+
{value ? 'ON · Ativo' : 'OFF · Inativo'}
|
|
41
|
+
</Typography>
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Default: Story = {
|
|
47
|
+
render: () => <InteractiveSwitch />,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const Disabled: Story = {
|
|
51
|
+
render: () => (
|
|
52
|
+
<View style={{ gap: 20 }}>
|
|
53
|
+
<InteractiveSwitch initialValue={false} disabled={true} />
|
|
54
|
+
<InteractiveSwitch initialValue={true} disabled={true} />
|
|
55
|
+
</View>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
|
3
|
+
import type { ViewStyle } from 'react-native';
|
|
4
|
+
import { theme } from '../../../styles/theme';
|
|
5
|
+
import { useResponsive } from '../../../hooks/useResponsive';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Componente Switch (Pílula) para opções binárias do tipo ON/OFF.
|
|
9
|
+
* Ideal para definições rápidas como "Ativar notificações".
|
|
10
|
+
*/
|
|
11
|
+
export interface SwitchProps {
|
|
12
|
+
/** Valor booleano indicando se está ativo (ON) ou não (OFF). */
|
|
13
|
+
value: boolean;
|
|
14
|
+
/** Função chamada com o novo estado ao clicar. */
|
|
15
|
+
onValueChange: (value: boolean) => void;
|
|
16
|
+
/** Impede a interação e aplica um efeito visual de inatividade. */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Estilos opcionais do container exterior. */
|
|
19
|
+
style?: ViewStyle | ViewStyle[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const Switch = ({
|
|
23
|
+
value,
|
|
24
|
+
onValueChange,
|
|
25
|
+
disabled = false,
|
|
26
|
+
style,
|
|
27
|
+
}: SwitchProps) => {
|
|
28
|
+
const { scale } = useResponsive();
|
|
29
|
+
|
|
30
|
+
const animatedValue = useRef(new Animated.Value(value ? 1 : 0)).current;
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
Animated.timing(animatedValue, {
|
|
34
|
+
toValue: value ? 1 : 0,
|
|
35
|
+
duration: 200,
|
|
36
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1), // ease-smooth
|
|
37
|
+
useNativeDriver: false, // backgroundColor cannot be animated with native driver
|
|
38
|
+
}).start();
|
|
39
|
+
}, [value, animatedValue]);
|
|
40
|
+
|
|
41
|
+
// Dimensions
|
|
42
|
+
const trackWidth = scale(50);
|
|
43
|
+
const trackHeight = scale(28);
|
|
44
|
+
const thumbSize = scale(22);
|
|
45
|
+
const padding = scale(3);
|
|
46
|
+
|
|
47
|
+
// Travel distance: full width - thumb size - (padding * 2)
|
|
48
|
+
const travelDistance = trackWidth - thumbSize - (padding * 2);
|
|
49
|
+
|
|
50
|
+
const translateX = animatedValue.interpolate({
|
|
51
|
+
inputRange: [0, 1],
|
|
52
|
+
outputRange: [padding, padding + travelDistance],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const backgroundColor = animatedValue.interpolate({
|
|
56
|
+
inputRange: [0, 1],
|
|
57
|
+
outputRange: [theme.colors.brown[100], theme.colors.primary], // #efe2d6 to #f2974b
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const handlePress = () => {
|
|
61
|
+
if (!disabled) {
|
|
62
|
+
onValueChange(!value);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Pressable
|
|
68
|
+
onPress={handlePress}
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
style={[
|
|
71
|
+
{
|
|
72
|
+
opacity: disabled ? 0.5 : 1,
|
|
73
|
+
},
|
|
74
|
+
style
|
|
75
|
+
]}
|
|
76
|
+
accessibilityRole="switch"
|
|
77
|
+
accessibilityState={{ checked: value, disabled }}
|
|
78
|
+
>
|
|
79
|
+
<Animated.View
|
|
80
|
+
style={[
|
|
81
|
+
styles.track,
|
|
82
|
+
{
|
|
83
|
+
width: trackWidth,
|
|
84
|
+
height: trackHeight,
|
|
85
|
+
borderRadius: trackHeight / 2,
|
|
86
|
+
backgroundColor,
|
|
87
|
+
}
|
|
88
|
+
]}
|
|
89
|
+
>
|
|
90
|
+
<Animated.View
|
|
91
|
+
style={[
|
|
92
|
+
styles.thumb,
|
|
93
|
+
{
|
|
94
|
+
width: thumbSize,
|
|
95
|
+
height: thumbSize,
|
|
96
|
+
borderRadius: thumbSize / 2,
|
|
97
|
+
transform: [{ translateX }],
|
|
98
|
+
}
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
</Animated.View>
|
|
102
|
+
</Pressable>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const styles = StyleSheet.create({
|
|
107
|
+
track: {
|
|
108
|
+
justifyContent: 'center',
|
|
109
|
+
},
|
|
110
|
+
thumb: {
|
|
111
|
+
backgroundColor: theme.colors.white,
|
|
112
|
+
shadowColor: '#000',
|
|
113
|
+
shadowOffset: { width: 0, height: 1 },
|
|
114
|
+
shadowOpacity: 0.18,
|
|
115
|
+
shadowRadius: 4,
|
|
116
|
+
elevation: 2,
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
left: 0,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Switch';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { TimeSlot } from './TimeSlot';
|
|
5
|
+
import type { TimeOption } from './TimeSlot';
|
|
6
|
+
import { Typography } from '../../Typography/Typography';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Components/Forms/TimeSlot',
|
|
10
|
+
component: TimeSlot,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: 'padded',
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component: `O **TimeSlot** é uma grelha de horários disponíveis para marcação de aulas. Apresenta um layout em flex-wrap com slots de tempo selecionáveis.
|
|
16
|
+
|
|
17
|
+
### Exemplo de Uso
|
|
18
|
+
\`\`\`tsx
|
|
19
|
+
<TimeSlot
|
|
20
|
+
slots={[
|
|
21
|
+
{ id: "1", time: "10:00", status: "available" }
|
|
22
|
+
]}
|
|
23
|
+
selectedId="1"
|
|
24
|
+
onSelectTime={(id, time) => console.log(time)}
|
|
25
|
+
/>
|
|
26
|
+
\`\`\``,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
tags: ['autodocs'],
|
|
31
|
+
} satisfies Meta<any>;
|
|
32
|
+
|
|
33
|
+
export default meta;
|
|
34
|
+
type Story = StoryObj<any>;
|
|
35
|
+
|
|
36
|
+
const mockSlots: TimeOption[] = [
|
|
37
|
+
{ id: '1', time: '10:00', status: 'unavailable' },
|
|
38
|
+
{ id: '2', time: '17:30', status: 'available' },
|
|
39
|
+
{ id: '3', time: '18:00', status: 'available' },
|
|
40
|
+
{ id: '4', time: '19:00', status: 'available' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const TimeSlotDemo = () => {
|
|
44
|
+
const [selected, setSelected] = useState<string>('2');
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={{ width: '100%', maxWidth: 400, backgroundColor: '#fdf6f0', padding: 24, borderRadius: 16 }}>
|
|
48
|
+
<Typography style={{ fontFamily: 'Nunito-Bold', marginBottom: 12, color: '#9a8478', textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
49
|
+
Horário
|
|
50
|
+
</Typography>
|
|
51
|
+
<TimeSlot
|
|
52
|
+
slots={mockSlots}
|
|
53
|
+
selectedId={selected}
|
|
54
|
+
onSelectTime={(id) => setSelected(id)}
|
|
55
|
+
/>
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const Default: Story = {
|
|
61
|
+
render: () => <TimeSlotDemo />,
|
|
62
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { View, StyleSheet, Pressable, Platform } from 'react-native';
|
|
2
|
+
import type { ViewStyle } from 'react-native';
|
|
3
|
+
import { theme } from '../../../styles/theme';
|
|
4
|
+
import { useResponsive } from '../../../hooks/useResponsive';
|
|
5
|
+
import { Typography } from '../../Typography/Typography';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Interface para os dados de um horário disponível no TimeSlot.
|
|
9
|
+
*/
|
|
10
|
+
export interface TimeOption {
|
|
11
|
+
/** Identificador único do slot (ex: "1") */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Texto do horário a apresentar (ex: "10:00") */
|
|
14
|
+
time: string;
|
|
15
|
+
/** Estado de disponibilidade do horário */
|
|
16
|
+
status: 'available' | 'unavailable';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Componente TimeSlot
|
|
21
|
+
*
|
|
22
|
+
* Uma grelha de horários disponíveis para marcação (ex: aulas).
|
|
23
|
+
* Renderiza opções de tempo em formato de pílula retangular (flex-wrap).
|
|
24
|
+
*/
|
|
25
|
+
export interface TimeSlotProps {
|
|
26
|
+
/** Array de horários a renderizar no seletor */
|
|
27
|
+
slots: TimeOption[];
|
|
28
|
+
/** ID do horário atualmente selecionado */
|
|
29
|
+
selectedId?: string;
|
|
30
|
+
/** Callback chamado quando o utilizador clica num horário disponível */
|
|
31
|
+
onSelectTime: (id: string, time: string) => void;
|
|
32
|
+
/** Estilo para o container exterior */
|
|
33
|
+
style?: ViewStyle | ViewStyle[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const TimeSlot = ({
|
|
37
|
+
slots,
|
|
38
|
+
selectedId,
|
|
39
|
+
onSelectTime,
|
|
40
|
+
style,
|
|
41
|
+
}: TimeSlotProps) => {
|
|
42
|
+
const { scale, scaleText } = useResponsive();
|
|
43
|
+
|
|
44
|
+
// Transição suave para o Web
|
|
45
|
+
const transitionStyle = Platform.OS === 'web' ? { transition: 'all 0.15s ease-in-out' } as any : {};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View style={style}>
|
|
49
|
+
<View style={[styles.container, { gap: scale(8), paddingBottom: scale(6), paddingTop: scale(2) }]}>
|
|
50
|
+
{slots.map((slot) => {
|
|
51
|
+
const isSelected = slot.id === selectedId;
|
|
52
|
+
const isUnavailable = slot.status === 'unavailable';
|
|
53
|
+
|
|
54
|
+
let bgColor = '#fff';
|
|
55
|
+
let borderColor = '#efe2d6';
|
|
56
|
+
let textColor = '#51332d';
|
|
57
|
+
let borderWidth = scale(1.5);
|
|
58
|
+
|
|
59
|
+
if (isSelected) {
|
|
60
|
+
borderColor = theme.colors.primary; // #f2974b
|
|
61
|
+
borderWidth = scale(2);
|
|
62
|
+
} else if (isUnavailable) {
|
|
63
|
+
textColor = '#c3b1a4';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Pressable
|
|
68
|
+
key={slot.id}
|
|
69
|
+
disabled={isUnavailable}
|
|
70
|
+
onPress={() => onSelectTime(slot.id, slot.time)}
|
|
71
|
+
style={({ pressed }) => [
|
|
72
|
+
styles.item,
|
|
73
|
+
transitionStyle,
|
|
74
|
+
{
|
|
75
|
+
paddingVertical: scale(9),
|
|
76
|
+
paddingHorizontal: scale(14),
|
|
77
|
+
borderRadius: scale(11),
|
|
78
|
+
backgroundColor: bgColor,
|
|
79
|
+
borderWidth: borderWidth,
|
|
80
|
+
borderColor,
|
|
81
|
+
minHeight: scale(44),
|
|
82
|
+
},
|
|
83
|
+
isSelected && {
|
|
84
|
+
shadowColor: 'rgba(242,151,75,0.25)',
|
|
85
|
+
shadowOffset: { width: 0, height: pressed ? 0 : 3 },
|
|
86
|
+
shadowOpacity: 1,
|
|
87
|
+
shadowRadius: 0,
|
|
88
|
+
elevation: 0,
|
|
89
|
+
transform: [{ translateY: pressed ? 3 : 0 }]
|
|
90
|
+
}
|
|
91
|
+
]}
|
|
92
|
+
>
|
|
93
|
+
<Typography style={[styles.text, { fontSize: scaleText(14), color: textColor }, transitionStyle]}>
|
|
94
|
+
{slot.time}
|
|
95
|
+
</Typography>
|
|
96
|
+
</Pressable>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</View>
|
|
100
|
+
</View>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const styles = StyleSheet.create({
|
|
105
|
+
container: {
|
|
106
|
+
flexDirection: 'row',
|
|
107
|
+
flexWrap: 'wrap',
|
|
108
|
+
},
|
|
109
|
+
item: {
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
justifyContent: 'center',
|
|
112
|
+
},
|
|
113
|
+
text: {
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
});
|