@compsych/mobile-ui 1.0.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,353 @@
1
+ import { Ionicons } from '@expo/vector-icons';
2
+ import React from 'react';
3
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
4
+ import { sys } from './tokens';
5
+
6
+ export type PaginationSize = 'sm' | 'lg';
7
+
8
+ // Shared token shape so sub-components accept either size
9
+ type SizeTokens = {
10
+ itemSize: number;
11
+ iconSize: number;
12
+ fontSize: number;
13
+ lineHeight: number;
14
+ activeWeight: '500';
15
+ inactiveWeight: '400';
16
+ gap: number;
17
+ hasPill: boolean;
18
+ pillPaddingH: number;
19
+ pillPaddingV: number;
20
+ pillGap: number;
21
+ };
22
+
23
+ export interface PaginationProps {
24
+ size?: PaginationSize;
25
+ totalPages: number;
26
+ currentPage: number;
27
+ onPageChange: (page: number) => void;
28
+ /** How many page numbers to show on each side of the current page */
29
+ siblingCount?: number;
30
+ /** On mobile/compact layouts — renders only prev + next arrows (no page numbers) */
31
+ compact?: boolean;
32
+ }
33
+
34
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
35
+
36
+ // ── Size tokens ───────────────────────────────────────────────────────────────
37
+
38
+ const SIZE_TOKENS = {
39
+ sm: {
40
+ itemSize: 32,
41
+ iconSize: 20,
42
+ fontSize: ts.labelMedium.sysFontSize,
43
+ lineHeight: ts.labelMedium.sysLineHeight,
44
+ activeWeight: '500' as const,
45
+ inactiveWeight: '400' as const,
46
+ gap: dim.spacing.padding.sysPadding8,
47
+ // Pill container: sm has NO outer pill
48
+ hasPill: false,
49
+ pillPaddingH: 0,
50
+ pillPaddingV: 0,
51
+ pillGap: dim.spacing.padding.sysPadding8,
52
+ },
53
+ lg: {
54
+ itemSize: 40,
55
+ iconSize: 24,
56
+ fontSize: ts.labelLarge.sysFontSize,
57
+ lineHeight: ts.labelLarge.sysLineHeight,
58
+ activeWeight: '500' as const,
59
+ inactiveWeight: '400' as const,
60
+ gap: dim.spacing.padding.sysPadding16,
61
+ // Pill container: lg wraps in pill border
62
+ hasPill: true,
63
+ pillPaddingH: dim.spacing.padding.sysPadding12,
64
+ pillPaddingV: dim.spacing.padding.sysPadding8,
65
+ pillGap: dim.spacing.padding.sysPadding16,
66
+ },
67
+ };
68
+
69
+ // ── Pagination range logic ────────────────────────────────────────────────────
70
+
71
+ function buildPageRange(
72
+ total: number,
73
+ current: number,
74
+ siblings: number,
75
+ ): (number | '...')[] {
76
+ // Always show: 1, last, current, siblings
77
+ const range: (number | '...')[] = [];
78
+
79
+ if (total <= 1) return [1];
80
+
81
+ const left = Math.max(2, current - siblings);
82
+ const right = Math.min(total - 1, current + siblings);
83
+
84
+ // First page
85
+ range.push(1);
86
+
87
+ // Left ellipsis
88
+ if (left > 2) range.push('...');
89
+
90
+ // Middle range
91
+ for (let i = left; i <= right; i++) range.push(i);
92
+
93
+ // Right ellipsis
94
+ if (right < total - 1) range.push('...');
95
+
96
+ // Last page
97
+ if (total > 1) range.push(total);
98
+
99
+ return range;
100
+ }
101
+
102
+ // ── Active ring wrapper ───────────────────────────────────────────────────────
103
+ // Mirrors the 4px padding + sysPrimary08 background pattern from Input.
104
+
105
+ const RING_SIZE = 4;
106
+
107
+ // ── Page item ────────────────────────────────────────────────────────────────
108
+
109
+ interface PageItemProps {
110
+ s: SizeTokens;
111
+ page: number;
112
+ isActive: boolean;
113
+ onPress: () => void;
114
+ }
115
+
116
+ function PageItem({ s, page, isActive, onPress }: PageItemProps) {
117
+ const borderWidth = isActive ? dim.borderWidth.sysStrokeMedium : 0;
118
+ const borderColor = isActive ? cr.accent.primary.sysPrimary : 'transparent';
119
+ const textColor = isActive
120
+ ? cr.custom.info.sysOnInfoContainer
121
+ : cr.surface.surface.sysOnSurface;
122
+ const fontWeight = isActive ? s.activeWeight : s.inactiveWeight;
123
+
124
+ return (
125
+ <Pressable
126
+ onPress={onPress}
127
+ accessibilityRole="button"
128
+ accessibilityLabel={`Page ${page}`}
129
+ accessibilityState={{ selected: isActive }}
130
+ >
131
+ {({ pressed }) => (
132
+ // Ring wrapper — always RING_SIZE padding for layout stability;
133
+ // background only when active (or focused — handled via pressed state here)
134
+ <View
135
+ style={{
136
+ padding: RING_SIZE,
137
+ borderRadius: s.itemSize / 2 + RING_SIZE,
138
+ backgroundColor: isActive
139
+ ? cr.transparent.primary.sysPrimary08
140
+ : 'transparent',
141
+ }}
142
+ >
143
+ <View
144
+ style={{
145
+ width: s.itemSize,
146
+ height: s.itemSize,
147
+ borderRadius: s.itemSize / 2,
148
+ borderWidth,
149
+ borderColor,
150
+ alignItems: 'center',
151
+ justifyContent: 'center',
152
+ backgroundColor: pressed && !isActive
153
+ ? cr.transparent.neutral.sysBlack10
154
+ : 'transparent',
155
+ }}
156
+ >
157
+ <Text
158
+ style={{
159
+ fontSize: s.fontSize,
160
+ lineHeight: s.lineHeight,
161
+ fontWeight,
162
+ color: textColor,
163
+ includeFontPadding: false,
164
+ }}
165
+ >
166
+ {page}
167
+ </Text>
168
+ </View>
169
+ </View>
170
+ )}
171
+ </Pressable>
172
+ );
173
+ }
174
+
175
+ // ── Ellipsis item ─────────────────────────────────────────────────────────────
176
+
177
+ function EllipsisItem({ s }: { s: SizeTokens }) {
178
+ return (
179
+ <View
180
+ accessible
181
+ accessibilityLabel="More pages"
182
+ style={{
183
+ width: s.itemSize + RING_SIZE * 2,
184
+ height: s.itemSize + RING_SIZE * 2,
185
+ alignItems: 'center',
186
+ justifyContent: 'center',
187
+ }}
188
+ >
189
+ <Text
190
+ style={{
191
+ fontSize: s.fontSize,
192
+ lineHeight: s.lineHeight,
193
+ fontWeight: s.inactiveWeight,
194
+ color: cr.surface.surface.sysOnSurfaceVariant,
195
+ includeFontPadding: false,
196
+ }}
197
+ >
198
+
199
+ </Text>
200
+ </View>
201
+ );
202
+ }
203
+
204
+ // ── Prev / Next button ────────────────────────────────────────────────────────
205
+
206
+ interface NavButtonProps {
207
+ s: SizeTokens;
208
+ direction: 'prev' | 'next';
209
+ disabled: boolean;
210
+ onPress: () => void;
211
+ }
212
+
213
+ function NavButton({ s, direction, disabled, onPress }: NavButtonProps) {
214
+ const iconName =
215
+ direction === 'prev' ? 'chevron-back' : 'chevron-forward';
216
+
217
+ return (
218
+ <Pressable
219
+ onPress={disabled ? undefined : onPress}
220
+ accessibilityRole="button"
221
+ accessibilityLabel={direction === 'prev' ? 'Previous page' : 'Next page'}
222
+ accessibilityState={{ disabled }}
223
+ style={{ opacity: disabled ? 0.38 : 1 }}
224
+ >
225
+ {({ pressed }) => (
226
+ <View
227
+ style={{
228
+ width: s.itemSize,
229
+ height: s.itemSize,
230
+ borderRadius: s.itemSize / 2,
231
+ alignItems: 'center',
232
+ justifyContent: 'center',
233
+ backgroundColor: pressed && !disabled
234
+ ? cr.transparent.neutral.sysBlack10
235
+ : 'transparent',
236
+ }}
237
+ >
238
+ <Ionicons
239
+ name={iconName}
240
+ size={s.iconSize}
241
+ color={cr.surface.surface.sysOnSurface}
242
+ />
243
+ </View>
244
+ )}
245
+ </Pressable>
246
+ );
247
+ }
248
+
249
+ // ── Main component ────────────────────────────────────────────────────────────
250
+
251
+ export function Pagination({
252
+ size = 'lg',
253
+ totalPages,
254
+ currentPage,
255
+ onPageChange,
256
+ siblingCount = 1,
257
+ compact = false,
258
+ }: PaginationProps) {
259
+ const s = SIZE_TOKENS[size];
260
+ const pages = buildPageRange(totalPages, currentPage, siblingCount);
261
+
262
+ const prevDisabled = currentPage <= 1;
263
+ const nextDisabled = currentPage >= totalPages;
264
+
265
+ // ── Compact (prev + next only) ────────────────────────────────────────────
266
+ const content = compact ? (
267
+ <View style={[styles.inner, { gap: dim.spacing.padding.sysPadding4 }]}>
268
+ <NavButton
269
+ s={s}
270
+ direction="prev"
271
+ disabled={prevDisabled}
272
+ onPress={() => onPageChange(currentPage - 1)}
273
+ />
274
+ <NavButton
275
+ s={s}
276
+ direction="next"
277
+ disabled={nextDisabled}
278
+ onPress={() => onPageChange(currentPage + 1)}
279
+ />
280
+ </View>
281
+ ) : (
282
+ // ── Full paginator ────────────────────────────────────────────────────
283
+ <View style={[styles.inner, { gap: s.gap }]}>
284
+ <NavButton
285
+ s={s}
286
+ direction="prev"
287
+ disabled={prevDisabled}
288
+ onPress={() => onPageChange(currentPage - 1)}
289
+ />
290
+
291
+ <View style={[styles.pages, { gap: s.hasPill ? 0 : s.gap }]}>
292
+ {pages.map((p, i) =>
293
+ p === '...' ? (
294
+ <EllipsisItem key={`ellipsis-${i}`} s={s} />
295
+ ) : (
296
+ <PageItem
297
+ key={p}
298
+ s={s}
299
+ page={p}
300
+ isActive={p === currentPage}
301
+ onPress={() => onPageChange(p)}
302
+ />
303
+ ),
304
+ )}
305
+ </View>
306
+
307
+ <NavButton
308
+ s={s}
309
+ direction="next"
310
+ disabled={nextDisabled}
311
+ onPress={() => onPageChange(currentPage + 1)}
312
+ />
313
+ </View>
314
+ );
315
+
316
+ // ── Pill wrapper (lg only) ────────────────────────────────────────────────
317
+ if (s.hasPill) {
318
+ return (
319
+ <View
320
+ style={[
321
+ styles.pill,
322
+ {
323
+ paddingHorizontal: s.pillPaddingH,
324
+ paddingVertical: s.pillPaddingV,
325
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerLowest,
326
+ borderColor: cr.outline.sysOutline,
327
+ borderWidth: dim.borderWidth.sysStrokeThin,
328
+ },
329
+ ]}
330
+ >
331
+ {content}
332
+ </View>
333
+ );
334
+ }
335
+
336
+ return content;
337
+ }
338
+
339
+ const styles = StyleSheet.create({
340
+ pill: {
341
+ alignSelf: 'flex-start',
342
+ borderRadius: 9999,
343
+ overflow: 'hidden',
344
+ },
345
+ inner: {
346
+ flexDirection: 'row',
347
+ alignItems: 'center',
348
+ },
349
+ pages: {
350
+ flexDirection: 'row',
351
+ alignItems: 'center',
352
+ },
353
+ });
@@ -0,0 +1,160 @@
1
+ import React from 'react';
2
+ import { StyleSheet, Text, View } from 'react-native';
3
+ import { sys } from './tokens';
4
+
5
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
6
+
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // ProgressBar — standalone horizontal bar
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+
11
+ export interface ProgressBarProps {
12
+ /** 0–100 */
13
+ progress: number;
14
+ }
15
+
16
+ export function ProgressBar({ progress }: ProgressBarProps) {
17
+ const clamped = Math.min(100, Math.max(0, progress));
18
+
19
+ return (
20
+ <View
21
+ accessible
22
+ accessibilityRole="progressbar"
23
+ accessibilityValue={{ min: 0, max: 100, now: clamped }}
24
+ style={styles.track}
25
+ >
26
+ {clamped > 0 && (
27
+ <View
28
+ style={[
29
+ styles.fill,
30
+ // Use percentage width — relies on the parent having a defined
31
+ // width (which it always does as flex-stretch in the tracker).
32
+ { width: `${clamped}%` },
33
+ ]}
34
+ />
35
+ )}
36
+ </View>
37
+ );
38
+ }
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // ProgressTracker — multi-step labelled tracker
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ export type StepState = 'completed' | 'active' | 'pending';
45
+
46
+ export interface TrackerStep {
47
+ label: string;
48
+ /**
49
+ * `completed` → full green bar
50
+ * `active` → partial green bar (25 % fill — the "in-progress" indicator)
51
+ * `pending` → empty track
52
+ */
53
+ state: StepState;
54
+ }
55
+
56
+ export type ProgressTrackerSize = 'sm' | 'lg';
57
+
58
+ export interface ProgressTrackerProps {
59
+ steps: TrackerStep[];
60
+ size?: ProgressTrackerSize;
61
+ /** Show step labels — default true */
62
+ showLabels?: boolean;
63
+ }
64
+
65
+ const SIZE_TOKENS = {
66
+ sm: {
67
+ fontSize: ts.labelSmall.sysFontSize,
68
+ lineHeight: ts.labelSmall.sysLineHeight,
69
+ letterSpacing: ts.labelSmall.sysTracking,
70
+ labelBarGap: dim.spacing.padding.sysPadding8,
71
+ stepGap: dim.spacing.padding.sysPadding4,
72
+ },
73
+ lg: {
74
+ fontSize: ts.labelMedium.sysFontSize,
75
+ lineHeight: ts.labelMedium.sysLineHeight,
76
+ letterSpacing: ts.labelMedium.sysTracking,
77
+ labelBarGap: dim.spacing.padding.sysPadding16,
78
+ stepGap: dim.spacing.padding.sysPadding8,
79
+ },
80
+ };
81
+
82
+ function stepProgress(state: StepState): number {
83
+ switch (state) {
84
+ case 'completed': return 100;
85
+ case 'active': return 25;
86
+ case 'pending': return 0;
87
+ }
88
+ }
89
+
90
+ export function ProgressTracker({
91
+ steps,
92
+ size = 'lg',
93
+ showLabels = true,
94
+ }: ProgressTrackerProps) {
95
+ const s = SIZE_TOKENS[size];
96
+
97
+ return (
98
+ <View style={[styles.row, { gap: s.stepGap }]}>
99
+ {steps.map((step, i) => {
100
+ const isActive = step.state !== 'pending';
101
+ const labelColor = isActive
102
+ ? cr.surface.surface.sysOnSurface
103
+ : cr.surface.surface.sysOnSurfaceVariant;
104
+
105
+ return (
106
+ <View key={i} style={styles.step}>
107
+ {showLabels && (
108
+ <Text
109
+ style={{
110
+ color: labelColor,
111
+ fontSize: s.fontSize,
112
+ lineHeight: s.lineHeight,
113
+ letterSpacing: s.letterSpacing,
114
+ includeFontPadding: false,
115
+ marginBottom: s.labelBarGap,
116
+ }}
117
+ numberOfLines={1}
118
+ >
119
+ {step.label}
120
+ </Text>
121
+ )}
122
+ <ProgressBar progress={stepProgress(step.state)} />
123
+ </View>
124
+ );
125
+ })}
126
+ </View>
127
+ );
128
+ }
129
+
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+ // Shared styles
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+
134
+ const styles = StyleSheet.create({
135
+ // ProgressBar
136
+ track: {
137
+ height: 4,
138
+ width: '100%',
139
+ borderRadius: 9999,
140
+ backgroundColor: cr.surface.surfaceContainer.sysSurfaceContainerHighest,
141
+ overflow: 'hidden',
142
+ },
143
+ fill: {
144
+ position: 'absolute',
145
+ top: 0,
146
+ bottom: 0,
147
+ left: 0,
148
+ borderRadius: 9999,
149
+ backgroundColor: cr.custom.success.sysSuccess,
150
+ },
151
+ // ProgressTracker
152
+ row: {
153
+ flexDirection: 'row',
154
+ alignItems: 'flex-start',
155
+ },
156
+ step: {
157
+ flex: 1,
158
+ minWidth: 0,
159
+ },
160
+ });
@@ -0,0 +1,177 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { sys } from './tokens';
4
+
5
+ export type RadioButtonSize = 'sm' | 'md';
6
+
7
+ export interface RadioButtonProps {
8
+ checked?: boolean;
9
+ defaultChecked?: boolean;
10
+ onChange?: (checked: boolean) => void;
11
+ size?: RadioButtonSize;
12
+ label?: string;
13
+ description?: string;
14
+ disabled?: boolean;
15
+ invalid?: boolean;
16
+ }
17
+
18
+ const { colorRoles: cr, dimensions: dim, typeScale: ts } = sys;
19
+
20
+ const SIZE_TOKENS = {
21
+ sm: {
22
+ // Container (overall hit area)
23
+ hitArea: 36,
24
+ // Visible circle
25
+ circle: 20,
26
+ // Inner dot when selected
27
+ dot: 8,
28
+ fontSize: ts.bodySmall.sysFontSize,
29
+ lineHeight: ts.bodySmall.sysLineHeight,
30
+ },
31
+ md: {
32
+ hitArea: 40,
33
+ circle: 24,
34
+ dot: 10,
35
+ fontSize: ts.bodyMedium.sysFontSize,
36
+ lineHeight: ts.bodyMedium.sysLineHeight,
37
+ },
38
+ };
39
+
40
+ export function RadioButton({
41
+ checked: checkedProp,
42
+ defaultChecked = false,
43
+ onChange,
44
+ size = 'md',
45
+ label,
46
+ description,
47
+ disabled = false,
48
+ invalid = false,
49
+ }: RadioButtonProps) {
50
+ const [internalChecked, setInternalChecked] = useState<boolean>(defaultChecked);
51
+ const isControlled = checkedProp !== undefined;
52
+ const isChecked: boolean = isControlled ? (checkedProp as boolean) : internalChecked;
53
+
54
+ const s = SIZE_TOKENS[size];
55
+
56
+ function handlePress() {
57
+ if (disabled) return;
58
+ const next = !isChecked;
59
+ if (!isControlled) setInternalChecked(next);
60
+ onChange?.(next);
61
+ }
62
+
63
+ // Border/fill colours
64
+ const borderColor = invalid
65
+ ? cr.error.sysError
66
+ : isChecked
67
+ ? cr.accent.primary.sysPrimary
68
+ : cr.outline.sysOutlineFixed;
69
+
70
+ const circleBg = isChecked ? cr.accent.primary.sysPrimary : 'transparent';
71
+
72
+ return (
73
+ <Pressable
74
+ onPress={handlePress}
75
+ accessibilityRole="radio"
76
+ accessibilityLabel={label}
77
+ accessibilityState={{ checked: isChecked, disabled }}
78
+ style={({ pressed }) => [
79
+ styles.root,
80
+ disabled && styles.disabled,
81
+ ]}
82
+ >
83
+ {({ pressed }) => (
84
+ <>
85
+ {/* Hit-area / state-layer circle */}
86
+ <View
87
+ style={[
88
+ styles.hitArea,
89
+ {
90
+ width: s.hitArea,
91
+ height: s.hitArea,
92
+ borderRadius: s.hitArea / 2,
93
+ backgroundColor:
94
+ pressed && !disabled
95
+ ? cr.transparent.neutral.sysBlack10
96
+ : 'transparent',
97
+ },
98
+ ]}
99
+ >
100
+ {/* Visible radio circle */}
101
+ <View
102
+ style={{
103
+ width: s.circle,
104
+ height: s.circle,
105
+ borderRadius: s.circle / 2,
106
+ borderWidth: dim.borderWidth.sysStrokeMedium,
107
+ borderColor,
108
+ backgroundColor: circleBg,
109
+ alignItems: 'center',
110
+ justifyContent: 'center',
111
+ }}
112
+ >
113
+ {/* Inner white dot — only visible when selected */}
114
+ {isChecked && (
115
+ <View
116
+ style={{
117
+ width: s.dot,
118
+ height: s.dot,
119
+ borderRadius: s.dot / 2,
120
+ backgroundColor: cr.accent.primary.sysOnPrimary,
121
+ }}
122
+ />
123
+ )}
124
+ </View>
125
+ </View>
126
+
127
+ {(label || description) && (
128
+ <View style={[styles.textBlock, { gap: 2 }]}>
129
+ {label && (
130
+ <Text
131
+ style={{
132
+ color: cr.surface.surface.sysOnSurface,
133
+ fontSize: s.fontSize,
134
+ lineHeight: s.lineHeight,
135
+ includeFontPadding: false,
136
+ }}
137
+ >
138
+ {label}
139
+ </Text>
140
+ )}
141
+ {description && (
142
+ <Text
143
+ style={{
144
+ color: cr.surface.surface.sysOnSurfaceVariant,
145
+ fontSize: s.fontSize - 2,
146
+ lineHeight: s.lineHeight - 2,
147
+ includeFontPadding: false,
148
+ }}
149
+ >
150
+ {description}
151
+ </Text>
152
+ )}
153
+ </View>
154
+ )}
155
+ </>
156
+ )}
157
+ </Pressable>
158
+ );
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ root: {
163
+ flexDirection: 'row',
164
+ alignItems: 'center',
165
+ },
166
+ hitArea: {
167
+ alignItems: 'center',
168
+ justifyContent: 'center',
169
+ },
170
+ textBlock: {
171
+ flex: 1,
172
+ flexDirection: 'column',
173
+ },
174
+ disabled: {
175
+ opacity: 0.48,
176
+ },
177
+ });