@lotics/ui 1.11.1 → 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
+ });
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { View, ScrollView, Pressable, StyleSheet } from "react-native";
3
3
  import { Text } from "../text";
4
4
  import { colors } from "../colors";
5
- import { dayDiff } from "../calendar/dates";
5
+ import { addDays, dayDiff } from "../calendar/dates";
6
+ import { usePointerDrag } from "../use_pointer_drag";
6
7
  import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
7
8
  import { DEFAULT_GANTT_LABELS } from "./types";
8
9
  import type { GanttLabels, GanttScale, GanttTask } from "./types";
@@ -11,6 +12,8 @@ const LABEL_W = 188;
11
12
  const HEADER_H = 38;
12
13
  const ROW_H = 40;
13
14
  const BAR_H = 22;
15
+ const HANDLE_W = 8;
16
+ const MIN_BAR = 8;
14
17
  const SCALES: GanttScale[] = ["day", "week", "month"];
15
18
 
16
19
  export interface GanttViewProps<T = unknown> {
@@ -23,6 +26,9 @@ export interface GanttViewProps<T = unknown> {
23
26
  /** User-facing chrome strings; defaults to English. */
24
27
  labels?: Partial<GanttLabels>;
25
28
  onTaskPress?: (task: GanttTask<T>) => void;
29
+ /** When set, each bar gets a right-edge resize handle; dragging it adjusts the
30
+ * task's end. Receives the task + the new inclusive end day (clamped ≥ start). */
31
+ onTaskResize?: (task: GanttTask<T>, newEnd: Date) => void;
26
32
  }
27
33
 
28
34
  /**
@@ -33,10 +39,20 @@ export interface GanttViewProps<T = unknown> {
33
39
  * — wrap in a ScrollView for very long task lists.
34
40
  */
35
41
  export function GanttView<T = unknown>(props: GanttViewProps<T>) {
36
- const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress } = props;
42
+ const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress, onTaskResize } = props;
37
43
  const L = { ...DEFAULT_GANTT_LABELS, ...props.labels };
38
44
  const [scale, setScale] = useState<GanttScale>(defaultScale);
39
45
 
46
+ const taskById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
47
+ const { live, bind } = usePointerDrag((id, _pointer, delta) => {
48
+ const t = taskById.get(id);
49
+ if (!t || !onTaskResize) return;
50
+ const days = Math.round(delta.dx / pxPerDay(scale));
51
+ if (days === 0) return;
52
+ const newEnd = addDays(t.end ?? t.start, days);
53
+ onTaskResize(t, newEnd < t.start ? t.start : newEnd);
54
+ });
55
+
40
56
  const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
41
57
  const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
42
58
  const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
@@ -100,7 +116,9 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
100
116
 
101
117
  {/* Task rows */}
102
118
  {tasks.map((t) => {
103
- const bar = barGeometry(t, axisStart, scale);
119
+ const base = barGeometry(t, axisStart, scale);
120
+ const dragging = live && live.id === t.id ? live : null;
121
+ const width = dragging ? Math.max(MIN_BAR, base.width + dragging.dx) : base.width;
104
122
  const accent = t.color || colors.teal[600];
105
123
  return (
106
124
  <View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
@@ -113,12 +131,20 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
113
131
  onPress={onTaskPress ? () => onTaskPress(t) : undefined}
114
132
  accessibilityRole={onTaskPress ? "button" : undefined}
115
133
  accessibilityLabel={t.label}
116
- style={{ position: "absolute", left: bar.left, width: bar.width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
134
+ style={{ position: "absolute", left: base.left, width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
117
135
  >
118
- <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
136
+ <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8, opacity: dragging ? 0.85 : 1 }}>
119
137
  <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
120
138
  </View>
121
139
  </Pressable>
140
+ {onTaskResize ? (
141
+ <View
142
+ ref={bind(t.id, "ew-resize")}
143
+ style={{ position: "absolute", left: base.left + width - HANDLE_W, width: HANDLE_W + 6, top: (ROW_H - BAR_H) / 2, height: BAR_H, alignItems: "center", justifyContent: "center", zIndex: 2 }}
144
+ >
145
+ <View style={{ width: 3, height: 11, borderRadius: 2, backgroundColor: "rgba(255,255,255,0.75)" }} />
146
+ </View>
147
+ ) : null}
122
148
  </View>
123
149
  );
124
150
  })}
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>
package/src/popover.tsx CHANGED
@@ -3,6 +3,7 @@ import React, {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useRef,
7
8
  useState,
8
9
  } from "react";
@@ -203,12 +204,29 @@ export function PopoverContent(props: PopoverContentProps) {
203
204
  const [triggerWidth, setTriggerWidth] = useState<number>(0);
204
205
  const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
205
206
  const returnFocusRef = useRef<HTMLElement | null>(null);
207
+ // Last known trigger geometry. The trigger can unmount while the popover is
208
+ // open — a hover-revealed menu button stops being rendered the moment our
209
+ // overlay covers its row and the hover ends — so positioning cannot rely on
210
+ // `triggerRef.current` still being live when it runs.
211
+ const triggerRectRef = useRef<DOMRect | null>(null);
206
212
 
207
213
  const handleClose = useCallback(() => {
208
214
  if (!open) return;
209
215
  onOpenChange(false);
210
216
  }, [onOpenChange, open]);
211
217
 
218
+ // Snapshot the trigger geometry synchronously the moment we open, while it is
219
+ // guaranteed to still be mounted. `calculatePosition` runs later in a rAF, by
220
+ // which point a hover-revealed trigger may already be gone (our overlay covers
221
+ // its row, hover ends, the button unmounts). Capturing here keeps the popover
222
+ // anchored to where the trigger was instead of stranding it off-screen.
223
+ useLayoutEffect(() => {
224
+ if (!open || small) return;
225
+ if (triggerRef.current) {
226
+ triggerRectRef.current = triggerRef.current.getBoundingClientRect();
227
+ }
228
+ }, [open, small, triggerRef]);
229
+
212
230
  // Focus management: when the popover opens, remember what had focus and move
213
231
  // focus into the popover (the content div is tab-able via `tabIndex=-1`).
214
232
  // When it closes, restore focus to the prior element so keyboard users don't
@@ -280,10 +298,17 @@ export function PopoverContent(props: PopoverContentProps) {
280
298
  }, [open, small, onOpenChange]);
281
299
 
282
300
  const calculatePosition = useCallback(() => {
283
- if (!triggerRef.current || !popoverRef.current) return;
301
+ if (!popoverRef.current) return;
284
302
  if (small) return;
285
303
 
286
- const triggerRect = triggerRef.current.getBoundingClientRect();
304
+ // Prefer the live trigger when it is still mounted (so the popover tracks
305
+ // scroll/layout); fall back to the rect captured at open time when the
306
+ // trigger has since unmounted, instead of stranding the popover off-screen.
307
+ const liveTriggerRect = triggerRef.current?.getBoundingClientRect();
308
+ if (liveTriggerRect) triggerRectRef.current = liveTriggerRect;
309
+ const triggerRect = triggerRectRef.current;
310
+ if (!triggerRect) return;
311
+
287
312
  const popoverRect = popoverRef.current.getBoundingClientRect();
288
313
  const viewportWidth = window.innerWidth;
289
314
  const viewportHeight = window.innerHeight;
@@ -403,6 +428,7 @@ export function PopoverContent(props: PopoverContentProps) {
403
428
  if (!open) {
404
429
  setPosition((previous) => (previous === null ? previous : null));
405
430
  setIsBottomSheetShown((previous) => (previous ? false : previous));
431
+ triggerRectRef.current = null;
406
432
  return;
407
433
  }
408
434
 
@@ -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
+ }