@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.
Files changed (168) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/.storybook/main.ts +17 -0
  3. package/.storybook/preview.tsx +65 -0
  4. package/eslint.config.js +22 -0
  5. package/index.html +13 -0
  6. package/package.json +55 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icons.svg +24 -0
  9. package/src/App.css +184 -0
  10. package/src/App.tsx +122 -0
  11. package/src/BordasSombras.mdx +343 -0
  12. package/src/Colors.mdx +393 -0
  13. package/src/GrelhaLayout.mdx +375 -0
  14. package/src/Icons.mdx +42 -0
  15. package/src/MovimentoBreakpoints.mdx +379 -0
  16. package/src/Spacing.mdx +231 -0
  17. package/src/Typography.mdx +321 -0
  18. package/src/assets/Mascots/vava/estaticos/vava-acenar.svg +3 -0
  19. package/src/assets/Mascots/vava/estaticos/vava-acertou.svg +3 -0
  20. package/src/assets/Mascots/vava/estaticos/vava-celebrar.svg +3 -0
  21. package/src/assets/Mascots/vava/estaticos/vava-com-autocolantes.svg +3 -0
  22. package/src/assets/Mascots/vava/estaticos/vava-dormir.svg +3 -0
  23. package/src/assets/Mascots/vava/estaticos/vava-errou.svg +3 -0
  24. package/src/assets/Mascots/vava/estaticos/vava-feliz.svg +3 -0
  25. package/src/assets/Mascots/vava/estaticos/vava-neutra.svg +3 -0
  26. package/src/assets/Mascots/vava/estaticos/vava-original.svg +3 -0
  27. package/src/assets/Mascots/vava/estaticos/vava-pensar.svg +3 -0
  28. package/src/assets/Mascots/vava/estaticos/vava-surpresa.svg +3 -0
  29. package/src/assets/Mascots/yaya/avatar/yaya-avatar-falar.svg +7 -0
  30. package/src/assets/Mascots/yaya/avatar/yaya-avatar-feliz.svg +7 -0
  31. package/src/assets/Mascots/yaya/avatar/yaya-avatar-transparente.svg +6 -0
  32. package/src/assets/Mascots/yaya/estaticos/yaya-acertou.svg +13 -0
  33. package/src/assets/Mascots/yaya/estaticos/yaya-celebrar.svg +13 -0
  34. package/src/assets/Mascots/yaya/estaticos/yaya-dormir.svg +13 -0
  35. package/src/assets/Mascots/yaya/estaticos/yaya-errou.svg +13 -0
  36. package/src/assets/Mascots/yaya/estaticos/yaya-falar.svg +13 -0
  37. package/src/assets/Mascots/yaya/estaticos/yaya-feliz.svg +13 -0
  38. package/src/assets/Mascots/yaya/estaticos/yaya-neutra.svg +13 -0
  39. package/src/assets/Mascots/yaya/estaticos/yaya-pensar.svg +13 -0
  40. package/src/assets/Mascots/yaya/estaticos/yaya-surpresa.svg +13 -0
  41. package/src/assets/hero.png +0 -0
  42. package/src/assets/react.svg +1 -0
  43. package/src/assets/vite.svg +1 -0
  44. package/src/components/Buttons/BackButton/BackButton.stories.tsx +86 -0
  45. package/src/components/Buttons/BackButton/BackButton.tsx +107 -0
  46. package/src/components/Buttons/FabButton/FabButton.stories.tsx +74 -0
  47. package/src/components/Buttons/FabButton/FabButton.tsx +161 -0
  48. package/src/components/Buttons/IconButton/IconButton.stories.tsx +99 -0
  49. package/src/components/Buttons/IconButton/IconButton.tsx +189 -0
  50. package/src/components/Buttons/JourneyButton/JourneyButton.stories.tsx +80 -0
  51. package/src/components/Buttons/JourneyButton/JourneyButton.tsx +194 -0
  52. package/src/components/Buttons/MainButton/MainButton.stories.tsx +144 -0
  53. package/src/components/Buttons/MainButton/MainButton.tsx +258 -0
  54. package/src/components/Buttons/WordChip/WordChip.stories.tsx +84 -0
  55. package/src/components/Buttons/WordChip/WordChip.tsx +157 -0
  56. package/src/components/Buttons/WordChip/index.ts +1 -0
  57. package/src/components/Cards/AnswerOptionCard/AnswerOptionCard.stories.tsx +181 -0
  58. package/src/components/Cards/AnswerOptionCard/AnswerOptionCard.tsx +201 -0
  59. package/src/components/Cards/BaseCard/BaseCard.stories.tsx +148 -0
  60. package/src/components/Cards/BaseCard/BaseCard.tsx +181 -0
  61. package/src/components/Cards/BoardingPassCard/BoardingPassCard.stories.tsx +205 -0
  62. package/src/components/Cards/BoardingPassCard/BoardingPassCard.tsx +384 -0
  63. package/src/components/Cards/BoardingPassCard/FlightIcon.tsx +21 -0
  64. package/src/components/Cards/BoardingPassCard/FlightIcon.web.tsx +18 -0
  65. package/src/components/Cards/CountryCard/CountryCard.stories.tsx +97 -0
  66. package/src/components/Cards/CountryCard/CountryCard.tsx +185 -0
  67. package/src/components/Cards/LevelProgressCard/LevelProgressCard.stories.tsx +61 -0
  68. package/src/components/Cards/LevelProgressCard/LevelProgressCard.tsx +143 -0
  69. package/src/components/Cards/ModuleCard/ModuleCard.stories.tsx +108 -0
  70. package/src/components/Cards/ModuleCard/ModuleCard.tsx +304 -0
  71. package/src/components/Cards/ModuleCompletionCard/ModuleCompletionCard.stories.tsx +53 -0
  72. package/src/components/Cards/ModuleCompletionCard/ModuleCompletionCard.tsx +167 -0
  73. package/src/components/Cards/Passport/PassportCover.tsx +114 -0
  74. package/src/components/Cards/Passport/PassportIDPage.tsx +217 -0
  75. package/src/components/Cards/Passport/PassportStampPage.tsx +253 -0
  76. package/src/components/Cards/Passport/PassportViewer.stories.tsx +95 -0
  77. package/src/components/Cards/Passport/PassportViewer.tsx +328 -0
  78. package/src/components/Cards/PlanCard/PlanCard.stories.tsx +159 -0
  79. package/src/components/Cards/PlanCard/PlanCard.tsx +317 -0
  80. package/src/components/Cards/PricingCard/PricingCard.stories.tsx +83 -0
  81. package/src/components/Cards/PricingCard/PricingCard.tsx +144 -0
  82. package/src/components/Cards/SelectionCard/SelectionCard.stories.tsx +77 -0
  83. package/src/components/Cards/SelectionCard/SelectionCard.tsx +111 -0
  84. package/src/components/Cards/TeacherCard/TeacherCard.stories.tsx +126 -0
  85. package/src/components/Cards/TeacherCard/TeacherCard.tsx +236 -0
  86. package/src/components/Charts/WeeklyProgressChart/WeeklyProgressChart.stories.tsx +89 -0
  87. package/src/components/Charts/WeeklyProgressChart/WeeklyProgressChart.tsx +179 -0
  88. package/src/components/Charts/WeeklyProgressChart/index.ts +1 -0
  89. package/src/components/DataDisplay/Avatar/Avatar.stories.tsx +85 -0
  90. package/src/components/DataDisplay/Avatar/Avatar.tsx +118 -0
  91. package/src/components/DataDisplay/Avatar/index.ts +1 -0
  92. package/src/components/DataDisplay/Badge/Badge.stories.tsx +62 -0
  93. package/src/components/DataDisplay/Badge/Badge.tsx +146 -0
  94. package/src/components/DataDisplay/Flag/Flag.stories.tsx +98 -0
  95. package/src/components/DataDisplay/Flag/Flag.tsx +347 -0
  96. package/src/components/DataDisplay/Flag/Flag.web.tsx +334 -0
  97. package/src/components/DataDisplay/InstructionBubble/InstructionBubble.stories.tsx +80 -0
  98. package/src/components/DataDisplay/InstructionBubble/InstructionBubble.tsx +86 -0
  99. package/src/components/DataDisplay/LevelBadge/LevelBadge.stories.tsx +99 -0
  100. package/src/components/DataDisplay/LevelBadge/LevelBadge.tsx +118 -0
  101. package/src/components/DataDisplay/ProgressBar/ProgressBar.stories.tsx +95 -0
  102. package/src/components/DataDisplay/ProgressBar/ProgressBar.tsx +117 -0
  103. package/src/components/DataDisplay/StatBadge/StatBadge.stories.tsx +82 -0
  104. package/src/components/DataDisplay/StatBadge/StatBadge.tsx +135 -0
  105. package/src/components/DataDisplay/StatBadge/index.ts +1 -0
  106. package/src/components/DataDisplay/Tag/Tag.stories.tsx +63 -0
  107. package/src/components/DataDisplay/Tag/Tag.tsx +148 -0
  108. package/src/components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet.stories.tsx +74 -0
  109. package/src/components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet.tsx +200 -0
  110. package/src/components/Forms/Checkbox/Checkbox.stories.tsx +60 -0
  111. package/src/components/Forms/Checkbox/Checkbox.tsx +127 -0
  112. package/src/components/Forms/Checkbox/index.ts +1 -0
  113. package/src/components/Forms/DaySelector/DaySelector.stories.tsx +62 -0
  114. package/src/components/Forms/DaySelector/DaySelector.tsx +133 -0
  115. package/src/components/Forms/Radio/Radio.stories.tsx +61 -0
  116. package/src/components/Forms/Radio/Radio.tsx +141 -0
  117. package/src/components/Forms/Radio/index.ts +1 -0
  118. package/src/components/Forms/SegmentedToggle/SegmentedToggle.stories.tsx +67 -0
  119. package/src/components/Forms/SegmentedToggle/SegmentedToggle.tsx +150 -0
  120. package/src/components/Forms/SegmentedToggle/index.ts +1 -0
  121. package/src/components/Forms/Select/Select.stories.tsx +59 -0
  122. package/src/components/Forms/Select/Select.tsx +255 -0
  123. package/src/components/Forms/Select/index.ts +1 -0
  124. package/src/components/Forms/Switch/Switch.stories.tsx +57 -0
  125. package/src/components/Forms/Switch/Switch.tsx +120 -0
  126. package/src/components/Forms/Switch/index.ts +1 -0
  127. package/src/components/Forms/TimeSlot/TimeSlot.stories.tsx +62 -0
  128. package/src/components/Forms/TimeSlot/TimeSlot.tsx +116 -0
  129. package/src/components/Headers/CountryHeader/CountryHeader.stories.tsx +114 -0
  130. package/src/components/Headers/CountryHeader/CountryHeader.tsx +147 -0
  131. package/src/components/Headers/ProfileHeader/ProfileHeader.stories.tsx +141 -0
  132. package/src/components/Headers/ProfileHeader/ProfileHeader.tsx +233 -0
  133. package/src/components/Inputs/FillInTheBlank/FillInTheBlank.stories.tsx +333 -0
  134. package/src/components/Inputs/FillInTheBlank/FillInTheBlank.tsx +287 -0
  135. package/src/components/Inputs/FillInTheBlank/index.ts +1 -0
  136. package/src/components/Inputs/TextInput/TextInput.stories.tsx +103 -0
  137. package/src/components/Inputs/TextInput/TextInput.tsx +187 -0
  138. package/src/components/Mascots/Vava/Vava.stories.tsx +114 -0
  139. package/src/components/Mascots/Vava/Vava.tsx +276 -0
  140. package/src/components/Mascots/Yaya/Yaya.stories.tsx +98 -0
  141. package/src/components/Mascots/Yaya/Yaya.tsx +233 -0
  142. package/src/components/Navigation/BottomTabBar/BottomTabBar.stories.tsx +68 -0
  143. package/src/components/Navigation/BottomTabBar/BottomTabBar.tsx +85 -0
  144. package/src/components/Navigation/BottomTabBar/BottomTabBarItem.tsx +68 -0
  145. package/src/components/Navigation/BottomTabBar/index.ts +2 -0
  146. package/src/components/Navigation/PaginationDots/PaginationDots.stories.tsx +62 -0
  147. package/src/components/Navigation/PaginationDots/PaginationDots.tsx +81 -0
  148. package/src/components/Navigation/PaginationDots/index.ts +1 -0
  149. package/src/components/Navigation/ProgressMap/ProgressMap.stories.tsx +116 -0
  150. package/src/components/Navigation/ProgressMap/ProgressMap.tsx +827 -0
  151. package/src/components/Navigation/WorldProgressMap/WorldProgressMap.stories.tsx +169 -0
  152. package/src/components/Navigation/WorldProgressMap/WorldProgressMap.tsx +790 -0
  153. package/src/components/Typography/Typography.stories.tsx +127 -0
  154. package/src/components/Typography/Typography.tsx +53 -0
  155. package/src/hooks/useResponsive.ts +41 -0
  156. package/src/hooks/useTheme.ts +32 -0
  157. package/src/index.css +355 -0
  158. package/src/index.ts +28 -0
  159. package/src/main.tsx +10 -0
  160. package/src/mocks/codegenNativeComponent.ts +5 -0
  161. package/src/styles/countryThemes.ts +79 -0
  162. package/src/styles/theme.ts +297 -0
  163. package/src/utils/iconMap.ts +23 -0
  164. package/tsconfig.app.json +25 -0
  165. package/tsconfig.json +7 -0
  166. package/tsconfig.node.json +24 -0
  167. package/vite.config.ts +51 -0
  168. 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
+ });