@lotics/ui 1.12.0 → 1.13.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/package.json +7 -4
- package/src/accordion.tsx +97 -0
- package/src/button.tsx +1 -1
- package/src/cell_date_format.test.ts +32 -0
- package/src/cell_date_format.ts +28 -3
- package/src/checkbox_input.tsx +8 -3
- package/src/date_calendar.tsx +679 -0
- package/src/date_field.tsx +172 -0
- package/src/date_picker.tsx +403 -28
- package/src/date_picker_value.test.ts +167 -0
- package/src/date_picker_value.ts +128 -0
- package/src/date_segments.test.ts +206 -0
- package/src/date_segments.ts +347 -0
- package/src/date_segments_field.tsx +418 -0
- package/src/icon.tsx +2 -0
- package/src/menu_button.tsx +1 -1
- package/src/menu_list_item.tsx +1 -1
- package/src/radio_picker.tsx +1 -1
- package/src/stepper.tsx +83 -0
- package/src/switch.tsx +1 -1
- package/src/switch_button.tsx +1 -1
- package/src/tabs.tsx +1 -1
- package/src/time_field.tsx +300 -0
- package/src/datetime_picker.tsx +0 -44
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
NativeSyntheticEvent,
|
|
4
|
+
Platform,
|
|
5
|
+
StyleProp,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
TextInput as RNTextInput,
|
|
8
|
+
TextInputKeyPressEventData,
|
|
9
|
+
View,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
} from "react-native";
|
|
12
|
+
import { colors } from "./colors";
|
|
13
|
+
import { Text } from "./text";
|
|
14
|
+
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
15
|
+
import {
|
|
16
|
+
SegmentBuffer,
|
|
17
|
+
SegmentLabels,
|
|
18
|
+
SegmentType,
|
|
19
|
+
SegmentsConfig,
|
|
20
|
+
displayValue,
|
|
21
|
+
fieldOrder,
|
|
22
|
+
incrementSegment,
|
|
23
|
+
placeholderFor,
|
|
24
|
+
setHourField,
|
|
25
|
+
to12h,
|
|
26
|
+
typeDigit,
|
|
27
|
+
withDayPeriod,
|
|
28
|
+
} from "./date_segments";
|
|
29
|
+
|
|
30
|
+
export interface DateSegmentsProps {
|
|
31
|
+
/** Canonical value (`YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`), `""` when empty. */
|
|
32
|
+
value: string;
|
|
33
|
+
onChange: (value: string) => void;
|
|
34
|
+
/** Layout + value↔buffer mapping; see `dateSegmentsConfig`. */
|
|
35
|
+
config: SegmentsConfig;
|
|
36
|
+
/** Accessible names per segment. */
|
|
37
|
+
segmentLabels: SegmentLabels;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
autoFocus?: boolean;
|
|
40
|
+
testID?: string;
|
|
41
|
+
/** Accessible name for the segment group (e.g. "Start date"). */
|
|
42
|
+
accessibilityLabel?: string;
|
|
43
|
+
/** Fires when a segment in this group gains focus. */
|
|
44
|
+
onFocus?: () => void;
|
|
45
|
+
/** Fires when a segment in this group loses focus. */
|
|
46
|
+
onBlur?: () => void;
|
|
47
|
+
style?: StyleProp<ViewStyle>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- buffer transforms (pure) -----------------------------------------------
|
|
51
|
+
|
|
52
|
+
function applyField(
|
|
53
|
+
buffer: SegmentBuffer,
|
|
54
|
+
type: SegmentType,
|
|
55
|
+
value: number,
|
|
56
|
+
hour12: boolean,
|
|
57
|
+
): SegmentBuffer {
|
|
58
|
+
switch (type) {
|
|
59
|
+
case "year":
|
|
60
|
+
return { ...buffer, year: value };
|
|
61
|
+
case "month":
|
|
62
|
+
return { ...buffer, month: value };
|
|
63
|
+
case "day":
|
|
64
|
+
return { ...buffer, day: value };
|
|
65
|
+
case "minute":
|
|
66
|
+
return { ...buffer, minute: value };
|
|
67
|
+
case "hour":
|
|
68
|
+
return { ...buffer, hour: setHourField(buffer, value, hour12) };
|
|
69
|
+
case "dayPeriod":
|
|
70
|
+
return buffer;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearField(buffer: SegmentBuffer, type: SegmentType): SegmentBuffer {
|
|
75
|
+
switch (type) {
|
|
76
|
+
case "year":
|
|
77
|
+
return { ...buffer, year: null };
|
|
78
|
+
case "month":
|
|
79
|
+
return { ...buffer, month: null };
|
|
80
|
+
case "day":
|
|
81
|
+
return { ...buffer, day: null };
|
|
82
|
+
case "minute":
|
|
83
|
+
return { ...buffer, minute: null };
|
|
84
|
+
case "hour":
|
|
85
|
+
return { ...buffer, hour: null };
|
|
86
|
+
case "dayPeriod":
|
|
87
|
+
return buffer;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fieldNumeric(type: SegmentType, buffer: SegmentBuffer, hour12: boolean): number | null {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case "year":
|
|
94
|
+
return buffer.year;
|
|
95
|
+
case "month":
|
|
96
|
+
return buffer.month;
|
|
97
|
+
case "day":
|
|
98
|
+
return buffer.day;
|
|
99
|
+
case "minute":
|
|
100
|
+
return buffer.minute;
|
|
101
|
+
case "hour":
|
|
102
|
+
return buffer.hour == null ? null : hour12 ? to12h(buffer.hour).h12 : buffer.hour;
|
|
103
|
+
case "dayPeriod":
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Borderless segmented date / datetime editor — one editable segment per locale
|
|
110
|
+
* field (in locale order, 12/24h derived) plus literal separators. Emits a canonical
|
|
111
|
+
* ISO value only once the segments form a complete, valid date. The bordered frame,
|
|
112
|
+
* focus ring, and calendar button live in {@link DateField}, which composes one (or
|
|
113
|
+
* two, for a range) of these.
|
|
114
|
+
*
|
|
115
|
+
* Segment editing (digits, arrows, AM/PM) is keyboard-driven on web. On native the
|
|
116
|
+
* segments are plain numeric inputs — the calendar/sheet is the primary path there.
|
|
117
|
+
*/
|
|
118
|
+
export function DateSegments(props: DateSegmentsProps) {
|
|
119
|
+
const {
|
|
120
|
+
value,
|
|
121
|
+
onChange,
|
|
122
|
+
config,
|
|
123
|
+
segmentLabels,
|
|
124
|
+
disabled,
|
|
125
|
+
autoFocus,
|
|
126
|
+
testID,
|
|
127
|
+
accessibilityLabel,
|
|
128
|
+
onFocus,
|
|
129
|
+
onBlur,
|
|
130
|
+
style,
|
|
131
|
+
} = props;
|
|
132
|
+
|
|
133
|
+
const layout = config.layout;
|
|
134
|
+
const order = useMemo(() => fieldOrder(layout), [layout]);
|
|
135
|
+
const firstFieldIndex = useMemo(
|
|
136
|
+
() => layout.segments.findIndex((s) => s.kind === "field"),
|
|
137
|
+
[layout],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// The field owns an editing buffer (the resolved value is its output). It is
|
|
141
|
+
// resynced when `value` changes from outside (calendar pick, parent reset) but
|
|
142
|
+
// not while the user types a still-incomplete value — same controlled-with-buffer
|
|
143
|
+
// shape as TimePicker's draft.
|
|
144
|
+
const [buffer, setBuffer] = useState<SegmentBuffer>(() => config.toBuffer(value));
|
|
145
|
+
const [activeType, setActiveType] = useState<SegmentType | null>(null);
|
|
146
|
+
const [activeText, setActiveText] = useState("");
|
|
147
|
+
|
|
148
|
+
const lastEmitted = useRef<string>(value);
|
|
149
|
+
const fieldRefs = useRef<Partial<Record<SegmentType, RNTextInput | null>>>({});
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (value !== lastEmitted.current) {
|
|
153
|
+
lastEmitted.current = value;
|
|
154
|
+
setBuffer(config.toBuffer(value));
|
|
155
|
+
setActiveText("");
|
|
156
|
+
}
|
|
157
|
+
}, [value, config]);
|
|
158
|
+
|
|
159
|
+
const commit = useCallback(
|
|
160
|
+
(next: SegmentBuffer) => {
|
|
161
|
+
const out = config.toValue(next);
|
|
162
|
+
if (out !== null) {
|
|
163
|
+
lastEmitted.current = out;
|
|
164
|
+
onChange(out);
|
|
165
|
+
} else if (config.isEmpty(next)) {
|
|
166
|
+
lastEmitted.current = "";
|
|
167
|
+
onChange("");
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[config, onChange],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const focusField = useCallback((type: SegmentType | undefined) => {
|
|
174
|
+
if (type) fieldRefs.current[type]?.focus();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const focusNext = useCallback(
|
|
178
|
+
(type: SegmentType) => focusField(order[order.indexOf(type) + 1]),
|
|
179
|
+
[order, focusField],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const focusPrev = useCallback(
|
|
183
|
+
(type: SegmentType) => {
|
|
184
|
+
const i = order.indexOf(type);
|
|
185
|
+
if (i > 0) focusField(order[i - 1]);
|
|
186
|
+
},
|
|
187
|
+
[order, focusField],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const handleDigit = useCallback(
|
|
191
|
+
(type: SegmentType, digit: string) => {
|
|
192
|
+
if (type === "dayPeriod") return;
|
|
193
|
+
const currentText = activeType === type ? activeText : "";
|
|
194
|
+
const { text, value: v, complete } = typeDigit(type, currentText, digit, layout.hour12);
|
|
195
|
+
const next = applyField(buffer, type, v, layout.hour12);
|
|
196
|
+
setBuffer(next);
|
|
197
|
+
commit(next);
|
|
198
|
+
setActiveType(type);
|
|
199
|
+
if (complete) {
|
|
200
|
+
setActiveText("");
|
|
201
|
+
focusNext(type);
|
|
202
|
+
} else {
|
|
203
|
+
setActiveText(text);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[activeType, activeText, buffer, layout.hour12, commit, focusNext],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const handleStep = useCallback(
|
|
210
|
+
(type: SegmentType, delta: number) => {
|
|
211
|
+
let next: SegmentBuffer;
|
|
212
|
+
if (type === "dayPeriod") {
|
|
213
|
+
const pm = buffer.hour == null ? false : to12h(buffer.hour).pm;
|
|
214
|
+
next = { ...buffer, hour: withDayPeriod(buffer, !pm) };
|
|
215
|
+
} else {
|
|
216
|
+
const value = incrementSegment(
|
|
217
|
+
type,
|
|
218
|
+
fieldNumeric(type, buffer, layout.hour12),
|
|
219
|
+
delta,
|
|
220
|
+
layout.hour12,
|
|
221
|
+
);
|
|
222
|
+
next = applyField(buffer, type, value, layout.hour12);
|
|
223
|
+
}
|
|
224
|
+
setBuffer(next);
|
|
225
|
+
commit(next);
|
|
226
|
+
setActiveType(type);
|
|
227
|
+
setActiveText("");
|
|
228
|
+
},
|
|
229
|
+
[buffer, layout.hour12, commit],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const handleBackspace = useCallback(
|
|
233
|
+
(type: SegmentType) => {
|
|
234
|
+
if (activeType === type && activeText !== "") {
|
|
235
|
+
const text = activeText.slice(0, -1);
|
|
236
|
+
setActiveText(text);
|
|
237
|
+
const next =
|
|
238
|
+
text === ""
|
|
239
|
+
? clearField(buffer, type)
|
|
240
|
+
: applyField(buffer, type, Number(text), layout.hour12);
|
|
241
|
+
setBuffer(next);
|
|
242
|
+
commit(next);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (fieldNumeric(type, buffer, layout.hour12) == null) {
|
|
246
|
+
focusPrev(type);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const next = clearField(buffer, type);
|
|
250
|
+
setBuffer(next);
|
|
251
|
+
commit(next);
|
|
252
|
+
setActiveType(type);
|
|
253
|
+
setActiveText("");
|
|
254
|
+
},
|
|
255
|
+
[activeType, activeText, buffer, layout.hour12, commit, focusPrev],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const setHalfDay = useCallback(
|
|
259
|
+
(pm: boolean) => {
|
|
260
|
+
const next = { ...buffer, hour: withDayPeriod(buffer, pm) };
|
|
261
|
+
setBuffer(next);
|
|
262
|
+
commit(next);
|
|
263
|
+
setActiveText("");
|
|
264
|
+
},
|
|
265
|
+
[buffer, commit],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const handleKeyPress = useCallback(
|
|
269
|
+
(type: SegmentType, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
270
|
+
const key = e.nativeEvent.key;
|
|
271
|
+
if (/^[0-9]$/.test(key)) {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
handleDigit(type, key);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
switch (key) {
|
|
277
|
+
case "ArrowUp":
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
handleStep(type, 1);
|
|
280
|
+
break;
|
|
281
|
+
case "ArrowDown":
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
handleStep(type, -1);
|
|
284
|
+
break;
|
|
285
|
+
case "ArrowLeft":
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
focusPrev(type);
|
|
288
|
+
break;
|
|
289
|
+
case "ArrowRight":
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
focusNext(type);
|
|
292
|
+
break;
|
|
293
|
+
case "Backspace":
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
handleBackspace(type);
|
|
296
|
+
break;
|
|
297
|
+
default:
|
|
298
|
+
if (type === "dayPeriod") {
|
|
299
|
+
const k = key.toLowerCase();
|
|
300
|
+
if (k === "a") {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
setHalfDay(false);
|
|
303
|
+
} else if (k === "p") {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
setHalfDay(true);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
[handleDigit, handleStep, handleBackspace, focusPrev, focusNext, setHalfDay],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const handleChangeText = useCallback(
|
|
314
|
+
(type: SegmentType, txt: string) => {
|
|
315
|
+
// Web digit/arrow keys are handled (and prevented) in onKeyPress, so this fires
|
|
316
|
+
// only for a paste or for native typing.
|
|
317
|
+
if (txt.length > 1 || /[^0-9]/.test(txt)) {
|
|
318
|
+
const parsed = config.parse(txt);
|
|
319
|
+
if (parsed) {
|
|
320
|
+
const next = config.toBuffer(parsed);
|
|
321
|
+
setBuffer(next);
|
|
322
|
+
commit(next);
|
|
323
|
+
setActiveText("");
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (Platform.OS !== "web" && txt) handleDigit(type, txt);
|
|
328
|
+
},
|
|
329
|
+
[config, commit, handleDigit],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const handleFocus = useCallback(
|
|
333
|
+
(type: SegmentType) => {
|
|
334
|
+
setActiveType(type);
|
|
335
|
+
setActiveText("");
|
|
336
|
+
onFocus?.();
|
|
337
|
+
},
|
|
338
|
+
[onFocus],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const handleBlur = useCallback(() => {
|
|
342
|
+
setActiveType(null);
|
|
343
|
+
setActiveText("");
|
|
344
|
+
onBlur?.();
|
|
345
|
+
}, [onBlur]);
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<View style={[styles.segments, style]} testID={testID} accessibilityLabel={accessibilityLabel}>
|
|
349
|
+
{layout.segments.map((seg, i) => {
|
|
350
|
+
if (seg.kind === "literal") {
|
|
351
|
+
return (
|
|
352
|
+
<Text key={i} size="sm" color="muted">
|
|
353
|
+
{seg.value}
|
|
354
|
+
</Text>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const type = seg.type;
|
|
358
|
+
const committed = displayValue(type, buffer, layout);
|
|
359
|
+
const isActive = activeType === type && activeText !== "";
|
|
360
|
+
const text = isActive ? activeText : (committed ?? "");
|
|
361
|
+
return (
|
|
362
|
+
<RNTextInput
|
|
363
|
+
key={i}
|
|
364
|
+
ref={(node) => {
|
|
365
|
+
fieldRefs.current[type] = node;
|
|
366
|
+
}}
|
|
367
|
+
value={text}
|
|
368
|
+
placeholder={placeholderFor(type)}
|
|
369
|
+
placeholderTextColor={colors.zinc["400"]}
|
|
370
|
+
editable={!disabled}
|
|
371
|
+
inputMode={type === "dayPeriod" ? "text" : "numeric"}
|
|
372
|
+
autoFocus={autoFocus && i === firstFieldIndex}
|
|
373
|
+
role={Platform.OS === "web" ? "spinbutton" : undefined}
|
|
374
|
+
aria-label={segmentLabels[type]}
|
|
375
|
+
onKeyPress={(e) => handleKeyPress(type, e)}
|
|
376
|
+
onChangeText={(t) => handleChangeText(type, t)}
|
|
377
|
+
onFocus={() => handleFocus(type)}
|
|
378
|
+
onBlur={handleBlur}
|
|
379
|
+
style={[
|
|
380
|
+
styles.segment,
|
|
381
|
+
type === "year" ? styles.year : type === "dayPeriod" ? styles.ampm : styles.twoDigit,
|
|
382
|
+
disabled && styles.segmentDisabled,
|
|
383
|
+
]}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
})}
|
|
387
|
+
</View>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const styles = StyleSheet.create({
|
|
392
|
+
segments: {
|
|
393
|
+
flexDirection: "row",
|
|
394
|
+
alignItems: "center",
|
|
395
|
+
},
|
|
396
|
+
segment: {
|
|
397
|
+
...getInputTextStyle(),
|
|
398
|
+
fontFamily: fontFamilyRegular,
|
|
399
|
+
letterSpacing: -0.4,
|
|
400
|
+
color: colors.zinc["900"],
|
|
401
|
+
textAlign: "center",
|
|
402
|
+
paddingVertical: 0,
|
|
403
|
+
paddingHorizontal: 0,
|
|
404
|
+
outlineStyle: "none" as unknown as "solid",
|
|
405
|
+
},
|
|
406
|
+
segmentDisabled: {
|
|
407
|
+
color: colors.zinc["400"],
|
|
408
|
+
},
|
|
409
|
+
twoDigit: {
|
|
410
|
+
width: 26,
|
|
411
|
+
},
|
|
412
|
+
year: {
|
|
413
|
+
width: 38,
|
|
414
|
+
},
|
|
415
|
+
ampm: {
|
|
416
|
+
width: 28,
|
|
417
|
+
},
|
|
418
|
+
});
|
package/src/icon.tsx
CHANGED
|
@@ -31,6 +31,7 @@ import Ban from "lucide-react-native/dist/esm/icons/ban";
|
|
|
31
31
|
import Bell from "lucide-react-native/dist/esm/icons/bell";
|
|
32
32
|
import Bolt from "lucide-react-native/dist/esm/icons/bolt";
|
|
33
33
|
import Brain from "lucide-react-native/dist/esm/icons/brain";
|
|
34
|
+
import Building2 from "lucide-react-native/dist/esm/icons/building-2";
|
|
34
35
|
import BookMarked from "lucide-react-native/dist/esm/icons/book-marked";
|
|
35
36
|
import BookOpen from "lucide-react-native/dist/esm/icons/book-open";
|
|
36
37
|
import BookText from "lucide-react-native/dist/esm/icons/book-text";
|
|
@@ -218,6 +219,7 @@ const iconComponents = {
|
|
|
218
219
|
bell: Bell,
|
|
219
220
|
bold: Bold,
|
|
220
221
|
bolt: Bolt,
|
|
222
|
+
"building-2": Building2,
|
|
221
223
|
"book-marked": BookMarked,
|
|
222
224
|
"book-open": BookOpen,
|
|
223
225
|
"book-text": BookText,
|
package/src/menu_button.tsx
CHANGED
|
@@ -105,7 +105,7 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
105
105
|
style={containerStyle}
|
|
106
106
|
role={role}
|
|
107
107
|
accessibilityLabel={resolvedLabel}
|
|
108
|
-
|
|
108
|
+
aria-selected={!!selected} aria-disabled={disabled || undefined}
|
|
109
109
|
>
|
|
110
110
|
{inner}
|
|
111
111
|
</PressableHighlight>
|
package/src/menu_list_item.tsx
CHANGED
|
@@ -51,7 +51,7 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
51
51
|
style={containerStyle}
|
|
52
52
|
accessibilityRole="button"
|
|
53
53
|
accessibilityLabel={description ? `${title}, ${description}` : title}
|
|
54
|
-
|
|
54
|
+
aria-disabled={disabled || undefined}
|
|
55
55
|
>
|
|
56
56
|
{inner}
|
|
57
57
|
</PressableHighlight>
|
package/src/radio_picker.tsx
CHANGED
|
@@ -115,7 +115,7 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
115
115
|
onPress={handlePress}
|
|
116
116
|
accessibilityRole="radio"
|
|
117
117
|
accessibilityLabel={description ? `${label}, ${description}` : label}
|
|
118
|
-
|
|
118
|
+
aria-checked={selected}
|
|
119
119
|
// Roving tabindex: exactly one radio is the tab-stop. Arrow keys cycle.
|
|
120
120
|
focusable={isTabStop}
|
|
121
121
|
onKeyDown={onKeyDown}
|
package/src/stepper.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
|
|
6
|
+
export type StepStatus = "done" | "current" | "upcoming";
|
|
7
|
+
|
|
8
|
+
export interface StepperProps {
|
|
9
|
+
/** Ordered step labels, left → right. */
|
|
10
|
+
steps: string[];
|
|
11
|
+
/** Index of the in-progress step; lower indices render complete. */
|
|
12
|
+
current: number;
|
|
13
|
+
/** Accent for the completed track, current ring, and current label.
|
|
14
|
+
* Defaults to the neutral ink — pass a brand color to theme it. */
|
|
15
|
+
color?: string;
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function statusOf(index: number, current: number): StepStatus {
|
|
20
|
+
if (index < current) return "done";
|
|
21
|
+
if (index === current) return "current";
|
|
22
|
+
return "upcoming";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Horizontal step / status tracker — a row of milestones with completed,
|
|
27
|
+
* current, and upcoming states and a connecting track. For a vertical event
|
|
28
|
+
* log use `Timeline` instead.
|
|
29
|
+
*/
|
|
30
|
+
export function Stepper(props: StepperProps) {
|
|
31
|
+
const { steps, current, color = colors.zinc[900], accessibilityLabel } = props;
|
|
32
|
+
const last = steps.length - 1;
|
|
33
|
+
const safe = Math.max(0, Math.min(current, last));
|
|
34
|
+
return (
|
|
35
|
+
<View
|
|
36
|
+
accessibilityRole="progressbar"
|
|
37
|
+
accessibilityLabel={accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`}
|
|
38
|
+
style={{ flexDirection: "row" }}
|
|
39
|
+
>
|
|
40
|
+
{steps.map((label, i) => {
|
|
41
|
+
const status = statusOf(i, current);
|
|
42
|
+
const leftFilled = i <= current && i > 0;
|
|
43
|
+
const rightFilled = i < current && i < last;
|
|
44
|
+
const isCurrent = status === "current";
|
|
45
|
+
return (
|
|
46
|
+
<View key={label} style={{ flex: 1, alignItems: "center", gap: 8 }}>
|
|
47
|
+
<View style={{ flexDirection: "row", alignItems: "center", width: "100%" }}>
|
|
48
|
+
<View style={{ flex: 1, height: 2, backgroundColor: leftFilled ? color : colors.zinc[200] }} />
|
|
49
|
+
<StepDot status={status} color={color} />
|
|
50
|
+
<View style={{ flex: 1, height: 2, backgroundColor: rightFilled ? color : colors.zinc[200] }} />
|
|
51
|
+
</View>
|
|
52
|
+
<Text
|
|
53
|
+
size="xs"
|
|
54
|
+
color={isCurrent ? "default" : "muted"}
|
|
55
|
+
weight={isCurrent ? "semibold" : "regular"}
|
|
56
|
+
style={isCurrent ? { color } : undefined}
|
|
57
|
+
>
|
|
58
|
+
{label}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function StepDot({ status, color }: { status: StepStatus; color: string }) {
|
|
68
|
+
if (status === "done") {
|
|
69
|
+
return <Icon name="circle-check" size={16} color={color} />;
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<View
|
|
73
|
+
style={{
|
|
74
|
+
width: 16,
|
|
75
|
+
height: 16,
|
|
76
|
+
borderRadius: 8,
|
|
77
|
+
backgroundColor: colors.white,
|
|
78
|
+
borderWidth: status === "current" ? 3 : 1.5,
|
|
79
|
+
borderColor: status === "current" ? color : colors.zinc[300],
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
package/src/switch.tsx
CHANGED
|
@@ -76,7 +76,7 @@ export function Switch(props: SwitchProps) {
|
|
|
76
76
|
disabled={disabled}
|
|
77
77
|
accessibilityRole="switch"
|
|
78
78
|
accessibilityLabel={accessibilityLabel}
|
|
79
|
-
|
|
79
|
+
aria-checked={!!value} aria-disabled={disabled || undefined}
|
|
80
80
|
>
|
|
81
81
|
{content}
|
|
82
82
|
</Pressable>
|
package/src/switch_button.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export function SwitchButton(props: SwitchButtonProps) {
|
|
|
28
28
|
tooltip={tooltip}
|
|
29
29
|
accessibilityRole="switch"
|
|
30
30
|
accessibilityLabel={title}
|
|
31
|
-
|
|
31
|
+
aria-checked={!!value}
|
|
32
32
|
>
|
|
33
33
|
<View style={{ flexDirection: "row", gap: 8, alignItems: "center", flex: 1 }}>
|
|
34
34
|
{!!icon && <Icon name={icon} size={20} />}
|
package/src/tabs.tsx
CHANGED
|
@@ -121,7 +121,7 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
121
121
|
testID={option.testID}
|
|
122
122
|
accessibilityRole="tab"
|
|
123
123
|
accessibilityLabel={option.label}
|
|
124
|
-
|
|
124
|
+
aria-selected={selected}
|
|
125
125
|
// Roving tabindex: the selected tab is the tab-stop, others are reachable
|
|
126
126
|
// via arrow keys. When no tab matches the current selection (props.value
|
|
127
127
|
// is stale), the first tab is the fallback so the group stays keyboard-
|