@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.
- package/README.md +1015 -0
- package/package.json +26 -0
- package/src/Alert.tsx +372 -0
- package/src/Avatar.tsx +255 -0
- package/src/Badge.tsx +189 -0
- package/src/BodyText.tsx +62 -0
- package/src/Breadcrumb.tsx +192 -0
- package/src/Button.tsx +228 -0
- package/src/Card.tsx +152 -0
- package/src/Checkbox.tsx +182 -0
- package/src/Chip.tsx +291 -0
- package/src/Divider.tsx +60 -0
- package/src/EmptyState.tsx +364 -0
- package/src/HeaderText.tsx +69 -0
- package/src/Input.tsx +228 -0
- package/src/Pagination.tsx +353 -0
- package/src/ProgressTracker.tsx +160 -0
- package/src/RadioButton.tsx +177 -0
- package/src/Slider.tsx +347 -0
- package/src/Switch.tsx +183 -0
- package/src/Tooltip.tsx +214 -0
- package/src/index.ts +62 -0
- package/src/tokens.ts +121 -0
|
@@ -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
|
+
});
|