@nwartz/design-system 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { LayoutChangeEvent, PanResponder, StyleSheet, View } from 'react-native';
3
+
4
+ import { spacing } from '../tokens/spacing';
5
+ import { Text } from './Text';
6
+
7
+ export interface SliderProps {
8
+ value: number;
9
+ minimumValue?: number;
10
+ maximumValue?: number;
11
+ step?: number;
12
+ onValueChange: (value: number) => void;
13
+ disabled?: boolean;
14
+ showValueLabel?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Horizontal slider with draggable thumb and value label.
19
+ *
20
+ * ```tsx
21
+ * <Slider value={50} onValueChange={setValue} showValueLabel />
22
+ * ```
23
+ */
24
+ export function Slider({
25
+ value,
26
+ minimumValue = 0,
27
+ maximumValue = 100,
28
+ step = 1,
29
+ onValueChange,
30
+ disabled = false,
31
+ showValueLabel = true,
32
+ }: SliderProps) {
33
+ const trackWidthRef = useRef(0);
34
+
35
+ const range = maximumValue - minimumValue;
36
+ const percentage = range > 0 ? (value - minimumValue) / range : 0;
37
+
38
+ const clampToStep = useCallback(
39
+ (raw: number) => {
40
+ const stepped = Math.round((raw - minimumValue) / step) * step + minimumValue;
41
+
42
+ return Math.max(minimumValue, Math.min(maximumValue, stepped));
43
+ },
44
+ [minimumValue, maximumValue, step],
45
+ );
46
+
47
+ const panResponder = useRef(
48
+ PanResponder.create({
49
+ onStartShouldSetPanResponder: () => !disabled,
50
+ onMoveShouldSetPanResponder: () => !disabled,
51
+ onPanResponderMove: (_, gestureState) => {
52
+ if (trackWidthRef.current === 0) {
53
+ return;
54
+ }
55
+
56
+ const newX = Math.max(
57
+ 0,
58
+ Math.min(1, (gestureState.x0 + gestureState.dx) / trackWidthRef.current),
59
+ );
60
+ const rawValue = newX * range + minimumValue;
61
+
62
+ onValueChange(clampToStep(rawValue));
63
+ },
64
+ }),
65
+ ).current;
66
+
67
+ const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
68
+ trackWidthRef.current = e.nativeEvent.layout.width;
69
+ }, []);
70
+
71
+ return (
72
+ <View style={[styles.wrapper, disabled && styles.disabled]}>
73
+ {showValueLabel && (
74
+ <Text
75
+ variant="body"
76
+ textStyle={[styles.valueLabel, { color: '#101828' }]}>
77
+ {String(value)}%
78
+ </Text>
79
+ )}
80
+
81
+ <View
82
+ onLayout={onTrackLayout}
83
+ {...panResponder.panHandlers}
84
+ style={styles.trackContainer}>
85
+ <View style={styles.track}>
86
+ <View
87
+ style={[
88
+ styles.fill,
89
+ { width: `${percentage * 100}%` },
90
+ ]}
91
+ />
92
+ </View>
93
+
94
+ <View
95
+ style={[
96
+ styles.thumb,
97
+ {
98
+ left: `${percentage * 100}%`,
99
+ },
100
+ ]}>
101
+ <View style={styles.thumbInner} />
102
+ </View>
103
+ </View>
104
+ </View>
105
+ );
106
+ }
107
+
108
+ const styles = StyleSheet.create({
109
+ wrapper: {
110
+ gap: spacing.sm,
111
+ },
112
+ disabled: {
113
+ opacity: 0.32,
114
+ },
115
+ valueLabel: {
116
+ fontSize: 16,
117
+ lineHeight: 24,
118
+ fontWeight: '500',
119
+ textAlign: 'center',
120
+ },
121
+ trackContainer: {
122
+ position: 'relative',
123
+ height: 40,
124
+ justifyContent: 'center',
125
+ },
126
+ track: {
127
+ height: 8,
128
+ borderRadius: 4,
129
+ backgroundColor: '#e9e8e7',
130
+ overflow: 'hidden',
131
+ },
132
+ fill: {
133
+ height: 8,
134
+ borderRadius: 4,
135
+ backgroundColor: '#cefe00',
136
+ },
137
+ thumb: {
138
+ position: 'absolute',
139
+ width: 24,
140
+ height: 24,
141
+ marginLeft: -12,
142
+ top: 8,
143
+ alignItems: 'center',
144
+ justifyContent: 'center',
145
+ },
146
+ thumbInner: {
147
+ width: 24,
148
+ height: 24,
149
+ borderRadius: 12,
150
+ backgroundColor: '#ffffff',
151
+ borderWidth: 1.5,
152
+ borderColor: '#cefe00',
153
+ shadowColor: '#101828',
154
+ shadowOffset: { width: 0, height: 2 },
155
+ shadowOpacity: 0.16,
156
+ shadowRadius: 6,
157
+ elevation: 4,
158
+ },
159
+ });
@@ -0,0 +1,86 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
+
4
+ import { Text } from './Text';
5
+
6
+ export type TabMenuItem = {
7
+ id: string;
8
+ label: string;
9
+ };
10
+
11
+ export interface TabMenuProps {
12
+ tabs: TabMenuItem[];
13
+ activeTabId: string;
14
+ onTabChange: (tabId: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Tab menu with underline indicator.
19
+ *
20
+ * ```tsx
21
+ * <TabMenu
22
+ * tabs={[{ id: 'overview', label: 'Overview' }, { id: 'details', label: 'Details' }]}
23
+ * activeTabId="overview"
24
+ * onTabChange={setTab}
25
+ * />
26
+ * ```
27
+ */
28
+ export function TabMenu({ tabs, activeTabId, onTabChange }: TabMenuProps) {
29
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
30
+
31
+ return (
32
+ <View style={styles.container}>
33
+ {tabs.map((tab) => {
34
+ const isActive = tab.id === activeTabId;
35
+ const isHovered = tab.id === hoveredId;
36
+
37
+ return (
38
+ <Pressable
39
+ key={tab.id}
40
+ accessibilityRole="tab"
41
+ accessibilityState={{ selected: isActive }}
42
+ onPress={() => onTabChange(tab.id)}
43
+ onHoverIn={() => setHoveredId(tab.id)}
44
+ onHoverOut={() => setHoveredId(null)}
45
+ style={[
46
+ styles.tab,
47
+ {
48
+ borderBottomColor: isActive ? '#cefe00' : '#e9e8e7',
49
+ borderBottomWidth: isActive ? 2 : 1,
50
+ backgroundColor: isHovered && !isActive ? '#e9e8e7' : 'transparent',
51
+ },
52
+ ]}>
53
+ <Text
54
+ variant="body"
55
+ textStyle={[
56
+ styles.label,
57
+ {
58
+ color: isActive ? '#cefe00' : '#737271',
59
+ fontWeight: isActive ? '700' : '400',
60
+ },
61
+ ]}>
62
+ {tab.label}
63
+ </Text>
64
+ </Pressable>
65
+ );
66
+ })}
67
+ </View>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ container: {
73
+ flexDirection: 'row',
74
+ },
75
+ tab: {
76
+ flex: 1,
77
+ alignItems: 'center',
78
+ justifyContent: 'center',
79
+ paddingVertical: 4,
80
+ paddingBottom: 12,
81
+ },
82
+ label: {
83
+ fontSize: 16,
84
+ lineHeight: 24,
85
+ },
86
+ });
@@ -0,0 +1,176 @@
1
+ import { Pressable, StyleSheet, View } from 'react-native';
2
+
3
+ import { elevation } from '../tokens/elevation';
4
+ import { Icon } from './Icon';
5
+ import { Text } from './Text';
6
+
7
+ export interface TablePaginationProps {
8
+ currentPage: number;
9
+ totalPages: number;
10
+ onPageChange: (page: number) => void;
11
+ totalItems?: number;
12
+ pageSize?: number;
13
+ }
14
+
15
+ function getVisiblePages(current: number, total: number): (number | 'ellipsis')[] {
16
+ if (total <= 7) {
17
+ return Array.from({ length: total }, (_, i) => i + 1);
18
+ }
19
+
20
+ const pages: (number | 'ellipsis')[] = [1];
21
+
22
+ if (current > 3) {
23
+ pages.push('ellipsis');
24
+ }
25
+
26
+ const start = Math.max(2, current - 1);
27
+ const end = Math.min(total - 1, current + 1);
28
+
29
+ for (let i = start; i <= end; i++) {
30
+ pages.push(i);
31
+ }
32
+
33
+ if (current < total - 2) {
34
+ pages.push('ellipsis');
35
+ }
36
+
37
+ if (total > 1) {
38
+ pages.push(total);
39
+ }
40
+
41
+ return pages;
42
+ }
43
+
44
+ /**
45
+ * Pagination control for tables with page numbers and navigation buttons.
46
+ *
47
+ * ```tsx
48
+ * <TablePagination currentPage={2} totalPages={10} onPageChange={setPage} />
49
+ * ```
50
+ */
51
+ export function TablePagination({
52
+ currentPage,
53
+ totalPages,
54
+ onPageChange,
55
+ }: TablePaginationProps) {
56
+ const pages = getVisiblePages(currentPage, totalPages);
57
+ const canGoBack = currentPage > 0;
58
+ const canGoForward = currentPage < totalPages - 1;
59
+
60
+ return (
61
+ <View style={[styles.container, { borderTopColor: '#e9e8e7' }]}>
62
+ <Pressable
63
+ disabled={!canGoBack}
64
+ onPress={() => canGoBack && onPageChange(currentPage - 1)}
65
+ style={({ pressed }) => [
66
+ styles.navButton,
67
+ {
68
+ opacity: !canGoBack ? 0.32 : 1,
69
+ backgroundColor: '#faf9f7',
70
+ borderColor: '#d9d8d7',
71
+ },
72
+ pressed && { opacity: 0.6 },
73
+ ]}>
74
+ <View style={{ transform: [{ rotate: '180deg' }] }}>
75
+ <Icon name="caret-right" size={20} color="#101828" />
76
+ </View>
77
+ <Text variant="body" textStyle={{ color: '#101828', fontSize: 14, fontWeight: '600' }}>
78
+ Anterior
79
+ </Text>
80
+ </Pressable>
81
+
82
+ <View style={styles.pageNumbers}>
83
+ {pages.map((page, index) => {
84
+ if (page === 'ellipsis') {
85
+ return (
86
+ <View key={`ellipsis-${index}`} style={styles.pageCell}>
87
+ <Text variant="body" textStyle={{ color: '#737271', fontSize: 14, fontWeight: '500' }}>
88
+ ...
89
+ </Text>
90
+ </View>
91
+ );
92
+ }
93
+
94
+ const isActive = page - 1 === currentPage;
95
+
96
+ return (
97
+ <Pressable
98
+ key={page}
99
+ onPress={() => onPageChange(page - 1)}
100
+ style={[
101
+ styles.pageCell,
102
+ {
103
+ backgroundColor: isActive ? '#e9e8e7' : 'transparent',
104
+ borderRadius: 8,
105
+ },
106
+ isActive && {
107
+ borderColor: '#cefe00',
108
+ borderWidth: 1,
109
+ ...elevation.xs,
110
+ },
111
+ ]}>
112
+ <Text
113
+ variant="body"
114
+ textStyle={{
115
+ color: isActive ? '#141414' : '#737271',
116
+ fontSize: 14,
117
+ fontWeight: '500',
118
+ }}>
119
+ {page}
120
+ </Text>
121
+ </Pressable>
122
+ );
123
+ })}
124
+ </View>
125
+
126
+ <Pressable
127
+ disabled={!canGoForward}
128
+ onPress={() => canGoForward && onPageChange(currentPage + 1)}
129
+ style={({ pressed }) => [
130
+ styles.navButton,
131
+ {
132
+ opacity: !canGoForward ? 0.32 : 1,
133
+ backgroundColor: '#faf9f7',
134
+ borderColor: '#d9d8d7',
135
+ },
136
+ pressed && { opacity: 0.6 },
137
+ ]}>
138
+ <Text variant="body" textStyle={{ color: '#101828', fontSize: 14, fontWeight: '600' }}>
139
+ Próximo
140
+ </Text>
141
+ <Icon name="caret-right" size={20} color="#101828" />
142
+ </Pressable>
143
+ </View>
144
+ );
145
+ }
146
+
147
+ const styles = StyleSheet.create({
148
+ container: {
149
+ flexDirection: 'row',
150
+ alignItems: 'center',
151
+ justifyContent: 'space-between',
152
+ paddingVertical: 12,
153
+ paddingHorizontal: 24,
154
+ borderTopWidth: 1,
155
+ },
156
+ navButton: {
157
+ flexDirection: 'row',
158
+ alignItems: 'center',
159
+ gap: 8,
160
+ height: 36,
161
+ paddingHorizontal: 14,
162
+ borderRadius: 16,
163
+ borderWidth: 1,
164
+ },
165
+ pageNumbers: {
166
+ flexDirection: 'row',
167
+ alignItems: 'center',
168
+ gap: 2,
169
+ },
170
+ pageCell: {
171
+ width: 40,
172
+ height: 40,
173
+ alignItems: 'center',
174
+ justifyContent: 'center',
175
+ },
176
+ });
@@ -0,0 +1,128 @@
1
+ import { Pressable, StyleSheet, View, type ViewProps } from 'react-native';
2
+
3
+ import { Icon, type IconName } from './Icon';
4
+ import { Text } from './Text';
5
+
6
+ export type TagSize = 'sm' | 'md' | 'lg';
7
+
8
+ export interface TagProps extends ViewProps {
9
+ label: string;
10
+ size?: TagSize;
11
+ icon?: IconName;
12
+ trailingIcon?: IconName;
13
+ count?: number;
14
+ onPress?: () => void;
15
+ onTrailingPress?: () => void;
16
+ }
17
+
18
+ const sizeStyles = {
19
+ sm: { height: 24, paddingHorizontal: 8, fontSize: 12, lineHeight: 18, iconSize: 14, closeSize: 14, countFontSize: 12 },
20
+ md: { height: 24, paddingHorizontal: 9, fontSize: 14, lineHeight: 21, iconSize: 16, closeSize: 16, countFontSize: 12 },
21
+ lg: { height: 28, paddingHorizontal: 10, fontSize: 14, lineHeight: 21, iconSize: 16, closeSize: 20, countFontSize: 14 },
22
+ } as const;
23
+
24
+ /**
25
+ * Inline tag for filters, categories, and removable labels.
26
+ *
27
+ * ```tsx
28
+ * <Tag label="Sport" icon="star" />
29
+ * <Tag label="Filter" trailingIcon="x" onTrailingPress={clear} count={3} />
30
+ * ```
31
+ */
32
+ export function Tag({
33
+ label,
34
+ size = 'md',
35
+ icon,
36
+ trailingIcon,
37
+ count,
38
+ onPress,
39
+ onTrailingPress,
40
+ style,
41
+ ...rest
42
+ }: TagProps) {
43
+ const s = sizeStyles[size];
44
+
45
+ const content = (
46
+ <View style={[styles.row, { gap: 3 }]}>
47
+ {icon && <Icon name={icon} size={s.iconSize} color="#737271" />}
48
+
49
+ <Text
50
+ variant="caption"
51
+ textStyle={[styles.label, { color: '#737271', fontSize: s.fontSize, lineHeight: s.lineHeight }]}>
52
+ {label}
53
+ </Text>
54
+
55
+ {count !== undefined && (
56
+ <View style={[styles.countBadge, { backgroundColor: '#e9e8e7' }]}>
57
+ <Text
58
+ variant="caption"
59
+ textStyle={[styles.countText, { fontSize: s.countFontSize, color: '#737271' }]}>
60
+ {count}
61
+ </Text>
62
+ </View>
63
+ )}
64
+
65
+ {trailingIcon && (
66
+ <Pressable
67
+ onPress={onTrailingPress}
68
+ hitSlop={4}
69
+ style={styles.trailingButton}>
70
+ <Icon name={trailingIcon} size={s.closeSize} color="#737271" />
71
+ </Pressable>
72
+ )}
73
+ </View>
74
+ );
75
+
76
+ const containerStyle = [
77
+ styles.tag,
78
+ {
79
+ height: s.height,
80
+ paddingHorizontal: s.paddingHorizontal,
81
+ backgroundColor: '#faf9f7',
82
+ borderColor: '#d9d8d7',
83
+ borderWidth: 1,
84
+ borderRadius: 6,
85
+ },
86
+ style,
87
+ ];
88
+
89
+ if (onPress) {
90
+ return (
91
+ <Pressable accessibilityRole="button" onPress={onPress} style={containerStyle} {...rest}>
92
+ {content}
93
+ </Pressable>
94
+ );
95
+ }
96
+
97
+ return (
98
+ <View style={containerStyle} {...rest}>
99
+ {content}
100
+ </View>
101
+ );
102
+ }
103
+
104
+ const styles = StyleSheet.create({
105
+ tag: {
106
+ alignSelf: 'flex-start',
107
+ },
108
+ row: {
109
+ flexDirection: 'row',
110
+ alignItems: 'center',
111
+ },
112
+ label: {
113
+ fontWeight: '500',
114
+ },
115
+ trailingButton: {
116
+ padding: 2,
117
+ },
118
+ countBadge: {
119
+ borderRadius: 3,
120
+ paddingHorizontal: 4,
121
+ paddingVertical: 0,
122
+ alignItems: 'center',
123
+ justifyContent: 'center',
124
+ },
125
+ countText: {
126
+ fontWeight: '500',
127
+ },
128
+ });
@@ -0,0 +1,32 @@
1
+ import { Text as RNText, type TextProps as RNTextProps, type TextStyle, type StyleProp } from 'react-native';
2
+
3
+ import { useTheme } from '../theme';
4
+ import { typography, type TypographyToken } from '../tokens/typography';
5
+
6
+ export interface TextProps extends RNTextProps {
7
+ /** Preset typography token. Defaults to `body`. */
8
+ variant?: TypographyToken;
9
+ /** Override the text color from the resolved theme. */
10
+ color?: 'textPrimary' | 'textSecondary';
11
+ /** Additional style overrides. */
12
+ textStyle?: StyleProp<TextStyle>;
13
+ }
14
+
15
+ /**
16
+ * Theme-aware text component.
17
+ *
18
+ * ```tsx
19
+ * <Text variant="heading" color="textPrimary">Title</Text>
20
+ * ```
21
+ */
22
+ export function Text({ variant = 'body', color = 'textPrimary', textStyle, style, children, ...rest }: TextProps) {
23
+ const theme = useTheme();
24
+
25
+ return (
26
+ <RNText
27
+ style={[typography[variant], { color: theme.colors[color] }, textStyle, style]}
28
+ {...rest}>
29
+ {children}
30
+ </RNText>
31
+ );
32
+ }
@@ -0,0 +1,112 @@
1
+ import { Pressable, StyleSheet, View } from 'react-native';
2
+
3
+ import { Text } from './Text';
4
+
5
+ export type ToggleSize = 'sm' | 'md';
6
+
7
+ export interface ToggleProps {
8
+ value: boolean;
9
+ onValueChange: (value: boolean) => void;
10
+ label?: string;
11
+ disabled?: boolean;
12
+ size?: ToggleSize;
13
+ }
14
+
15
+ const sizeConfig = {
16
+ md: { trackWidth: 44, trackHeight: 24, thumbSize: 20, thumbOnX: 22, thumbOffX: 2, gap: 12 },
17
+ sm: { trackWidth: 36, trackHeight: 20, thumbSize: 16, thumbOnX: 18, thumbOffX: 2, gap: 8 },
18
+ } as const;
19
+
20
+ /**
21
+ * Toggle switch for boolean on/off state.
22
+ *
23
+ * ```tsx
24
+ * <Toggle value={enabled} onValueChange={setEnabled} label="Notifications" />
25
+ * <Toggle value={v} onValueChange={setV} size="sm" />
26
+ * ```
27
+ */
28
+ export function Toggle({ value, onValueChange, label, disabled = false, size = 'md' }: ToggleProps) {
29
+ const s = sizeConfig[size];
30
+
31
+ const trackColor = value ? '#cefe00' : '#e9e8e7';
32
+ const hoverTrackColor = value ? '#87ad00' : '#d9d8d7';
33
+
34
+ return (
35
+ <Pressable
36
+ accessibilityRole="switch"
37
+ accessibilityState={{ checked: value, disabled }}
38
+ disabled={disabled}
39
+ onPress={() => onValueChange(!value)}
40
+ style={({ pressed }) => [
41
+ styles.container,
42
+ { gap: s.gap },
43
+ pressed && styles.pressed,
44
+ disabled && styles.disabled,
45
+ ]}>
46
+ {({ pressed }) => (
47
+ <>
48
+ <View
49
+ style={[
50
+ styles.track,
51
+ {
52
+ width: s.trackWidth,
53
+ height: s.trackHeight,
54
+ borderRadius: 12,
55
+ backgroundColor: pressed ? hoverTrackColor : trackColor,
56
+ justifyContent: value ? 'flex-end' : 'flex-start',
57
+ paddingHorizontal: 2,
58
+ },
59
+ ]}>
60
+ <View
61
+ style={[
62
+ styles.thumb,
63
+ {
64
+ width: s.thumbSize,
65
+ height: s.thumbSize,
66
+ borderRadius: s.thumbSize / 2,
67
+ backgroundColor: '#ffffff',
68
+ shadowColor: '#101828',
69
+ shadowOffset: { width: 0, height: 2 },
70
+ shadowOpacity: 0.16,
71
+ shadowRadius: 6,
72
+ elevation: 3,
73
+ },
74
+ ]}
75
+ />
76
+ </View>
77
+
78
+ {label && (
79
+ <Text
80
+ variant="body"
81
+ textStyle={[styles.label, { color: '#141414' }]}>
82
+ {label}
83
+ </Text>
84
+ )}
85
+ </>
86
+ )}
87
+ </Pressable>
88
+ );
89
+ }
90
+
91
+ const styles = StyleSheet.create({
92
+ container: {
93
+ flexDirection: 'row',
94
+ alignItems: 'center',
95
+ },
96
+ pressed: {
97
+ opacity: 0.7,
98
+ },
99
+ disabled: {
100
+ opacity: 0.32,
101
+ },
102
+ track: {
103
+ alignItems: 'center',
104
+ justifyContent: 'center',
105
+ },
106
+ thumb: {},
107
+ label: {
108
+ fontSize: 16,
109
+ lineHeight: 24,
110
+ fontWeight: '500',
111
+ },
112
+ });