@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,133 @@
1
+
2
+ import { View, StyleSheet, ScrollView, Pressable, Platform } from 'react-native';
3
+ import type { ViewStyle } from 'react-native';
4
+ import { theme } from '../../../styles/theme';
5
+ import { useResponsive } from '../../../hooks/useResponsive';
6
+ import { Typography } from '../../Typography/Typography';
7
+
8
+ /**
9
+ * Interface para os dados de um dia disponível no DaySelector.
10
+ */
11
+ export interface DayOption {
12
+ /** Identificador único do dia (ex: "2024-03-23") */
13
+ id: string;
14
+ /** Objeto Date correspondente ao dia */
15
+ dateObj: Date;
16
+ /** Etiqueta do dia da semana (ex: "SEG") */
17
+ label: string;
18
+ /** Número do dia do mês (ex: "23") */
19
+ number: string;
20
+ /** Estado de disponibilidade do dia */
21
+ status: 'available' | 'unavailable';
22
+ }
23
+
24
+ /**
25
+ * Componente DaySelector
26
+ *
27
+ * Uma lista com scroll horizontal de dias, usada tipicamente para a marcação de aulas.
28
+ * O dia selecionado é destacado com as cores primárias e sombras suaves.
29
+ */
30
+ export interface DaySelectorProps {
31
+ /** Array de dias a renderizar no seletor */
32
+ days: DayOption[];
33
+ /** ID do dia atualmente selecionado */
34
+ selectedId?: string;
35
+ /** Callback chamado quando o utilizador clica num dia disponível */
36
+ onSelectDay: (id: string, date: Date) => void;
37
+ /** Estilo para o container exterior */
38
+ style?: ViewStyle | ViewStyle[];
39
+ }
40
+
41
+ export const DaySelector = ({
42
+ days,
43
+ selectedId,
44
+ onSelectDay,
45
+ style,
46
+ }: DaySelectorProps) => {
47
+ const { scale, scaleText } = useResponsive();
48
+
49
+ // Transição suave para o Web
50
+ const transitionStyle = Platform.OS === 'web' ? { transition: 'all 0.15s ease-in-out' } as any : {};
51
+
52
+ return (
53
+ <View style={style}>
54
+ <ScrollView
55
+ horizontal
56
+ showsHorizontalScrollIndicator={false}
57
+ contentContainerStyle={[styles.container, { gap: scale(8), paddingBottom: scale(6), paddingTop: scale(2) }]}
58
+ >
59
+ {days.map((day) => {
60
+ const isSelected = day.id === selectedId;
61
+ const isUnavailable = day.status === 'unavailable';
62
+
63
+ let bgColor = '#fff';
64
+ let borderColor = '#efe2d6';
65
+ let labelColor = '#9a8478';
66
+ let numColor = '#51332d';
67
+
68
+ if (isSelected) {
69
+ bgColor = theme.colors.primary; // #f2974b
70
+ borderColor = theme.colors.primary;
71
+ labelColor = '#fde9d5';
72
+ numColor = '#fff';
73
+ } else if (isUnavailable) {
74
+ labelColor = '#b9a392';
75
+ numColor = '#b9a392';
76
+ }
77
+
78
+ return (
79
+ <Pressable
80
+ key={day.id}
81
+ disabled={isUnavailable}
82
+ onPress={() => onSelectDay(day.id, day.dateObj)}
83
+ style={({ pressed }) => [
84
+ styles.item,
85
+ transitionStyle,
86
+ {
87
+ minWidth: scale(48),
88
+ paddingVertical: scale(8),
89
+ borderRadius: scale(13),
90
+ backgroundColor: bgColor,
91
+ borderWidth: scale(1.5),
92
+ borderColor,
93
+ },
94
+ isSelected && {
95
+ shadowColor: '#cf7a30',
96
+ shadowOffset: { width: 0, height: pressed ? 0 : 3 },
97
+ shadowOpacity: 1,
98
+ shadowRadius: 0,
99
+ elevation: 0,
100
+ transform: [{ translateY: pressed ? 3 : 0 }]
101
+ }
102
+ ]}
103
+ >
104
+ <Typography style={[styles.label, { fontSize: scaleText(11), color: labelColor }, transitionStyle]}>
105
+ {day.label}
106
+ </Typography>
107
+ <Typography style={[styles.number, { fontSize: scaleText(17), color: numColor }, transitionStyle]}>
108
+ {day.number}
109
+ </Typography>
110
+ </Pressable>
111
+ );
112
+ })}
113
+ </ScrollView>
114
+ </View>
115
+ );
116
+ };
117
+
118
+ const styles = StyleSheet.create({
119
+ container: {
120
+ flexDirection: 'row',
121
+ },
122
+ item: {
123
+ alignItems: 'center',
124
+ justifyContent: 'center',
125
+ },
126
+ label: {
127
+
128
+ textTransform: 'uppercase',
129
+ },
130
+ number: {
131
+
132
+ }
133
+ });
@@ -0,0 +1,61 @@
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 { Radio } from './Radio';
6
+ import { theme } from '../../../styles/theme';
7
+
8
+ const meta = {
9
+ title: 'Components/Forms/Radio',
10
+ component: Radio,
11
+ parameters: {
12
+ layout: 'padded',
13
+ docs: {
14
+ description: {
15
+ component: 'Um botão de **Radio** é usado quando o utilizador tem de selecionar **apenas uma** opção de entre várias opções disponíveis.\n\n### Como usar em grupo\nComo não existe um componente `RadioGroup` isolado, o padrão correto em React Native é teres um estado que guarda o "valor selecionado" e usares vários `<Radio />` lado a lado. \n\nExemplo:\n```tsx\nconst [selected, setSelected] = useState("valor_a");\n\nreturn (\n <View>\n <Radio \n label="Opção A"\n checked={selected === "valor_a"}\n onChange={() => setSelected("valor_a")}\n />\n <Radio \n label="Opção B"\n checked={selected === "valor_b"}\n onChange={() => setSelected("valor_b")}\n />\n </View>\n)\n```\nIsto garante máxima flexibilidade para posicionares os Radios na horizontal, vertical, ou misturados com outro layout.',
16
+ }
17
+ }
18
+ },
19
+ tags: ['autodocs'],
20
+ decorators: [
21
+ (Story) => (
22
+ <View style={{ padding: 40, backgroundColor: theme.colors.cream }}>
23
+ <Story />
24
+ </View>
25
+ ),
26
+ ],
27
+ } satisfies Meta<any>;
28
+
29
+ export default meta;
30
+ type Story = StoryObj<any>;
31
+
32
+ const InteractiveRadioGroup = () => {
33
+ const [selected, setSelected] = useState('diariamente');
34
+ return (
35
+ <View style={{ gap: 12 }}>
36
+ <Radio
37
+ checked={selected === 'diariamente'}
38
+ onChange={() => setSelected('diariamente')}
39
+ label="Diariamente"
40
+ />
41
+ <Radio
42
+ checked={selected === '3x_semana'}
43
+ onChange={() => setSelected('3x_semana')}
44
+ label="3x por semana"
45
+ />
46
+ </View>
47
+ );
48
+ };
49
+
50
+ export const Default: Story = {
51
+ render: () => <InteractiveRadioGroup />,
52
+ };
53
+
54
+ export const Disabled: Story = {
55
+ render: () => (
56
+ <View style={{ gap: 12 }}>
57
+ <Radio label="Marcado inativo" checked={true} onChange={() => { }} disabled={true} />
58
+ <Radio label="Desmarcado inativo" checked={false} onChange={() => { }} disabled={true} />
59
+ </View>
60
+ ),
61
+ };
@@ -0,0 +1,141 @@
1
+ import { View, StyleSheet, Pressable } 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
+ * Componente individual de Radio.
9
+ * Deve ser usado para opções mutuamente exclusivas (onde apenas uma pode ser selecionada).
10
+ */
11
+ export interface RadioProps {
12
+ /** O texto a ser exibido ao lado do botão de rádio. A linha inteira será clicável. */
13
+ label?: string;
14
+ /**
15
+ * Estado atual do rádio.
16
+ * Num grupo, apenas um componente deve ter `checked={true}` de cada vez.
17
+ */
18
+ checked: boolean;
19
+ /**
20
+ * Função chamada quando o botão é pressionado.
21
+ * Geralmente usada para atualizar o estado que guarda o valor selecionado no grupo.
22
+ */
23
+ onChange: (checked: boolean) => void;
24
+ /** Desativa a interação e diminui a opacidade do rádio. */
25
+ disabled?: boolean;
26
+ /** Estilos customizados opcionais aplicados ao container exterior. */
27
+ style?: ViewStyle | ViewStyle[];
28
+ }
29
+
30
+ export const Radio = ({
31
+ label,
32
+ checked,
33
+ onChange,
34
+ disabled = false,
35
+ style,
36
+ }: RadioProps) => {
37
+ const { scale, scaleText } = useResponsive();
38
+
39
+ const handlePress = () => {
40
+ if (!disabled) {
41
+ onChange(!checked);
42
+ }
43
+ };
44
+
45
+ // Cores dinâmicas com base no estado
46
+ let borderColor = theme.colors.brown[100]; // #efe2d6
47
+ let dotColor = theme.colors.primary; // #f2974b
48
+ let borderWidth = scale(2);
49
+
50
+ if (disabled) {
51
+ borderColor = theme.colors.brown[100]; // #efe2d6
52
+ if (checked) {
53
+ dotColor = theme.colors.brown[400]; // #b9a392 (cor muted para disabled)
54
+ }
55
+ } else {
56
+ if (checked) {
57
+ borderColor = theme.colors.primary; // #f2974b
58
+ borderWidth = scale(2.5);
59
+ } else {
60
+ borderColor = theme.colors.brown[100];
61
+ borderWidth = scale(2);
62
+ }
63
+ }
64
+
65
+ return (
66
+ <Pressable
67
+ onPress={handlePress}
68
+ disabled={disabled}
69
+ style={() => [
70
+ styles.container,
71
+ {
72
+ paddingVertical: scale(4),
73
+ gap: scale(12),
74
+ opacity: disabled && !checked ? 0.5 : 1, // Opacity .5 para unchecked disabled
75
+ },
76
+ style
77
+ ]}
78
+ accessibilityRole="radio"
79
+ accessibilityState={{ checked, disabled }}
80
+ >
81
+ <View
82
+ style={[
83
+ styles.circle,
84
+ {
85
+ width: scale(22),
86
+ height: scale(22),
87
+ borderRadius: scale(11),
88
+ borderWidth,
89
+ borderColor,
90
+ backgroundColor: theme.colors.white,
91
+ }
92
+ ]}
93
+ >
94
+ {checked && (
95
+ <View
96
+ style={[
97
+ styles.dot,
98
+ {
99
+ width: scale(10),
100
+ height: scale(10),
101
+ borderRadius: scale(5),
102
+ backgroundColor: dotColor,
103
+ }
104
+ ]}
105
+ />
106
+ )}
107
+ </View>
108
+
109
+ {label && (
110
+ <Typography
111
+ style={[
112
+ styles.label,
113
+ {
114
+ fontSize: scaleText(15),
115
+ color: theme.colors.brown[800], // #51332d
116
+ }
117
+ ]}
118
+ >
119
+ {label}
120
+ </Typography>
121
+ )}
122
+ </Pressable>
123
+ );
124
+ };
125
+
126
+ const styles = StyleSheet.create({
127
+ container: {
128
+ flexDirection: 'row',
129
+ alignItems: 'center',
130
+ },
131
+ circle: {
132
+ justifyContent: 'center',
133
+ alignItems: 'center',
134
+ },
135
+ dot: {
136
+ // Definido dinamicamente no componente
137
+ },
138
+ label: {
139
+
140
+ },
141
+ });
@@ -0,0 +1 @@
1
+ export * from './Radio';
@@ -0,0 +1,67 @@
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 { SegmentedToggle } from './SegmentedToggle';
6
+ import { theme } from '../../../styles/theme';
7
+
8
+ const meta = {
9
+ title: 'Components/Forms/SegmentedToggle',
10
+ component: SegmentedToggle,
11
+ parameters: {
12
+ layout: 'padded',
13
+ docs: {
14
+ description: {
15
+ component: 'O **SegmentedToggle** é uma alternativa muito elegante ao tradicional Dropdown (Select) ou ao grupo de Radios.\n\n### Exemplo de Uso\n```tsx\nconst [plan, setPlan] = useState("monthly");\n\nconst options = [\n { label: "Mensal", value: "monthly" },\n { label: "Anual (-20%)", value: "annual" }\n];\n\nreturn (\n <SegmentedToggle \n options={options}\n selectedValue={plan}\n onValueChange={setPlan}\n />\n)\n```',
16
+ }
17
+ }
18
+ },
19
+ tags: ['autodocs'],
20
+ decorators: [
21
+ (Story) => (
22
+ <View style={{ padding: 40, backgroundColor: theme.colors.cream }}>
23
+ <Story />
24
+ </View>
25
+ ),
26
+ ],
27
+ } satisfies Meta<any>;
28
+
29
+ export default meta;
30
+ type Story = StoryObj<any>;
31
+
32
+ const InteractiveToggle = ({ options, initialValue }: { options: any[], initialValue: string }) => {
33
+ const [value, setValue] = useState(initialValue);
34
+
35
+ return (
36
+ <SegmentedToggle
37
+ options={options}
38
+ selectedValue={value}
39
+ onValueChange={setValue}
40
+ />
41
+ );
42
+ };
43
+
44
+ export const TwoOptions: Story = {
45
+ render: () => (
46
+ <InteractiveToggle
47
+ options={[
48
+ { label: 'Mensal', value: 'mensal' },
49
+ { label: 'Anual', value: 'anual', highlightText: '-20%' },
50
+ ]}
51
+ initialValue="mensal"
52
+ />
53
+ ),
54
+ };
55
+
56
+ export const ThreeOptions: Story = {
57
+ render: () => (
58
+ <InteractiveToggle
59
+ options={[
60
+ { label: 'Fácil', value: 'facil' },
61
+ { label: 'Médio', value: 'medio' },
62
+ { label: 'Difícil', value: 'dificil' },
63
+ ]}
64
+ initialValue="medio"
65
+ />
66
+ ),
67
+ };
@@ -0,0 +1,150 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { View, StyleSheet, Pressable, Animated, Easing } from 'react-native';
3
+ import type { ViewStyle, LayoutChangeEvent } from 'react-native';
4
+ import { theme } from '../../../styles/theme';
5
+ import { useResponsive } from '../../../hooks/useResponsive';
6
+ import { Typography } from '../../Typography/Typography';
7
+
8
+ export interface SegmentedToggleOption {
9
+ label: string;
10
+ value: string;
11
+ highlightText?: string;
12
+ highlightColor?: string;
13
+ }
14
+
15
+ /**
16
+ * Componente SegmentedToggle para alternar entre 2 ou 3 opções curtas (ex: Mensal vs Anual).
17
+ * Apresenta uma barra deslumbante que desliza debaixo da opção selecionada.
18
+ */
19
+ export interface SegmentedToggleProps {
20
+ /** Array de opções a mostrar na barra. Recomenda-se entre 2 a 3 opções curtas. */
21
+ options: SegmentedToggleOption[];
22
+ /** O valor da opção atualmente selecionada. */
23
+ selectedValue: string;
24
+ /** Callback executado sempre que o utilizador escolhe uma opção diferente. */
25
+ onValueChange: (value: string) => void;
26
+ /** Estilos adicionais para a barra (ex: `maxWidth`). */
27
+ style?: ViewStyle | ViewStyle[];
28
+ }
29
+
30
+ export const SegmentedToggle = ({
31
+ options,
32
+ selectedValue,
33
+ onValueChange,
34
+ style,
35
+ }: SegmentedToggleProps) => {
36
+ const { scaleText, scale } = useResponsive();
37
+ const [containerWidth, setContainerWidth] = useState(0);
38
+
39
+ const padding = scale(3);
40
+ const gap = scale(3);
41
+
42
+ // Encontrar índice atual
43
+ const selectedIndex = options.findIndex((opt) => opt.value === selectedValue) ?? 0;
44
+
45
+ const animValue = useRef(new Animated.Value(selectedIndex)).current;
46
+
47
+ useEffect(() => {
48
+ Animated.timing(animValue, {
49
+ toValue: selectedIndex,
50
+ duration: 200,
51
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1), // ease-smooth
52
+ useNativeDriver: false,
53
+ }).start();
54
+ }, [selectedIndex, animValue]);
55
+
56
+ const handleLayout = (e: LayoutChangeEvent) => {
57
+ setContainerWidth(e.nativeEvent.layout.width);
58
+ };
59
+
60
+ // Se o containerWidth for 0 (ainda não medido), não mostramos o fundo deslizante
61
+ const availableWidth = containerWidth - (padding * 2);
62
+ const totalGap = (options.length - 1) * gap;
63
+ const itemWidth = (availableWidth - totalGap) / options.length;
64
+
65
+ const translateX = animValue.interpolate({
66
+ inputRange: options.map((_, i) => i),
67
+ outputRange: options.map((_, i) => i * (itemWidth + gap)),
68
+ });
69
+
70
+ return (
71
+ <View
72
+ style={[styles.container, { padding, gap, borderRadius: scale(12), maxWidth: 400, width: '100%' }, style]}
73
+ onLayout={handleLayout}
74
+ accessibilityRole="radiogroup"
75
+ >
76
+ {containerWidth > 0 && (
77
+ <Animated.View
78
+ style={[
79
+ styles.activeBackground,
80
+ {
81
+ width: itemWidth,
82
+ borderRadius: scale(10),
83
+ transform: [{ translateX }],
84
+ top: padding,
85
+ left: padding,
86
+ bottom: padding,
87
+ }
88
+ ]}
89
+ />
90
+ )}
91
+
92
+ {options.map((option, index) => {
93
+ const isActive = selectedIndex === index;
94
+ return (
95
+ <Pressable
96
+ key={option.value}
97
+ style={[styles.option]}
98
+ onPress={() => onValueChange(option.value)}
99
+ accessibilityRole="radio"
100
+ accessibilityState={{ checked: isActive }}
101
+ >
102
+ <Typography
103
+ style={[
104
+ styles.label,
105
+ {
106
+ fontSize: scaleText(13),
107
+ color: isActive ? '#51332d' : '#9a8478',
108
+ }
109
+ ]}
110
+ >
111
+ {option.label}
112
+ {option.highlightText && (
113
+ <Typography style={{ color: option.highlightColor || theme.colors.success, fontWeight: '900' }}>
114
+ {' '}{option.highlightText}
115
+ </Typography>
116
+ )}
117
+ </Typography>
118
+ </Pressable>
119
+ );
120
+ })}
121
+ </View>
122
+ );
123
+ };
124
+
125
+ const styles = StyleSheet.create({
126
+ container: {
127
+ flexDirection: 'row',
128
+ backgroundColor: theme.colors.brown[100], // #efe2d6
129
+ position: 'relative',
130
+ },
131
+ activeBackground: {
132
+ position: 'absolute',
133
+ backgroundColor: theme.colors.white,
134
+ shadowColor: 'rgba(81, 51, 45, 1)',
135
+ shadowOffset: { width: 0, height: 2 },
136
+ shadowOpacity: 0.1,
137
+ shadowRadius: 4,
138
+ elevation: 2,
139
+ },
140
+ option: {
141
+ flex: 1,
142
+ justifyContent: 'center',
143
+ alignItems: 'center',
144
+ paddingVertical: 8,
145
+ zIndex: 1, // Para garantir que o texto fica por cima do activeBackground
146
+ },
147
+ label: {
148
+
149
+ },
150
+ });
@@ -0,0 +1 @@
1
+ export * from './SegmentedToggle';
@@ -0,0 +1,59 @@
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 { Select } from './Select';
6
+ import { theme } from '../../../styles/theme';
7
+
8
+ const meta = {
9
+ title: 'Components/Forms/Select',
10
+ component: Select,
11
+ parameters: {
12
+ layout: 'padded',
13
+ docs: {
14
+ description: {
15
+ component: 'Um **Select** (Dropdown) permite que os utilizadores escolham um valor de uma lista através de um Bottom Sheet nativo.\n\n### Exemplo de Uso\n```tsx\nconst [language, setLanguage] = useState("");\n\nconst languages = [\n { label: "Português", value: "pt" },\n { label: "Inglês", value: "en" }\n];\n\nreturn (\n <Select \n options={languages}\n value={language}\n onChange={setLanguage}\n placeholder="Escolha um idioma..."\n />\n)\n```',
16
+ }
17
+ }
18
+ },
19
+ tags: ['autodocs'],
20
+ decorators: [
21
+ (Story) => (
22
+ <View style={{ padding: 40, backgroundColor: theme.colors.cream }}>
23
+ <Story />
24
+ </View>
25
+ ),
26
+ ],
27
+ } satisfies Meta<any>;
28
+
29
+ export default meta;
30
+ type Story = StoryObj<any>;
31
+
32
+ const LANGUAGES = [
33
+ { label: 'Português', value: 'pt' },
34
+ { label: 'Inglês', value: 'en' },
35
+ { label: 'Francês', value: 'fr' },
36
+ { label: 'Espanhol', value: 'es' },
37
+ { label: 'Alemão', value: 'de' },
38
+ ];
39
+
40
+ const InteractiveSelect = ({ disabled = false }: { disabled?: boolean }) => {
41
+ const [value, setValue] = useState('');
42
+ return (
43
+ <Select
44
+ options={LANGUAGES}
45
+ value={value}
46
+ onChange={setValue}
47
+ disabled={disabled}
48
+ placeholder="Escolhe um idioma"
49
+ />
50
+ );
51
+ };
52
+
53
+ export const Default: Story = {
54
+ render: () => <InteractiveSelect />,
55
+ };
56
+
57
+ export const Disabled: Story = {
58
+ render: () => <InteractiveSelect disabled={true} />,
59
+ };