@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.
@@ -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,
@@ -105,7 +105,7 @@ export function MenuButton(props: MenuButtonProps) {
105
105
  style={containerStyle}
106
106
  role={role}
107
107
  accessibilityLabel={resolvedLabel}
108
- accessibilityState={{ selected: !!selected, disabled: !!disabled }}
108
+ aria-selected={!!selected} aria-disabled={disabled || undefined}
109
109
  >
110
110
  {inner}
111
111
  </PressableHighlight>
@@ -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
- accessibilityState={{ disabled: !!disabled }}
54
+ aria-disabled={disabled || undefined}
55
55
  >
56
56
  {inner}
57
57
  </PressableHighlight>
@@ -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
- accessibilityState={{ checked: selected }}
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}
@@ -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
- accessibilityState={{ checked: !!value, disabled: !!disabled }}
79
+ aria-checked={!!value} aria-disabled={disabled || undefined}
80
80
  >
81
81
  {content}
82
82
  </Pressable>
@@ -28,7 +28,7 @@ export function SwitchButton(props: SwitchButtonProps) {
28
28
  tooltip={tooltip}
29
29
  accessibilityRole="switch"
30
30
  accessibilityLabel={title}
31
- accessibilityState={{ checked: !!value }}
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
- accessibilityState={{ selected }}
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-