@lotics/ui 3.5.0 → 4.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.
Files changed (55) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
package/src/callout.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from "react";
1
2
  import { View, StyleSheet } from "react-native";
2
3
  import { Text } from "./text";
3
4
  import { Icon, type IconName } from "./icon";
@@ -8,12 +9,12 @@ export type CalloutTone = "info" | "success" | "warning" | "error" | "neutral";
8
9
  interface CalloutProps {
9
10
  /** Sets the accent (icon + tint + border) and the default icon. Default "info". */
10
11
  tone?: CalloutTone;
11
- /** Optional bold heading above the message. */
12
- title?: string;
13
- /** The message body — kept in the default text color for legibility on the tint. */
14
- message: string;
15
12
  /** Override the per-tone default icon (any kit icon), or `null` to drop it. */
16
13
  icon?: IconName | null;
14
+ /** Compose the body with `CalloutTitle`, `CalloutText`, and `CalloutActions`. */
15
+ children: ReactNode;
16
+ /** Test id on the container. */
17
+ testID?: string;
17
18
  }
18
19
 
19
20
  interface ToneStyle {
@@ -23,11 +24,16 @@ interface ToneStyle {
23
24
  glyph: IconName;
24
25
  }
25
26
 
27
+ // Tint surface + SOFT hairline border + deep icon — matches the kit's toned-surface
28
+ // convention (Badge: bg-50 / border-100 / deep accent). A heavier 200 border + 600
29
+ // icon reads as a generic "library alert"; the soft 100 border lets the tint settle
30
+ // into a calm panel with the deep-700 icon carrying the tone. (Neutral keeps a 200
31
+ // border — zinc-100 is invisible on zinc-50.)
26
32
  const TONES: Record<CalloutTone, ToneStyle> = {
27
- info: { bg: colors.blue[50], border: colors.blue[200], icon: colors.blue[600], glyph: "info" },
28
- success: { bg: colors.emerald[50], border: colors.emerald[200], icon: colors.emerald[600], glyph: "circle-check" },
29
- warning: { bg: colors.amber[50], border: colors.amber[200], icon: colors.amber[600], glyph: "triangle-alert" },
30
- error: { bg: colors.red[50], border: colors.red[200], icon: colors.red[600], glyph: "circle-alert" },
33
+ info: { bg: colors.blue[50], border: colors.blue[100], icon: colors.blue[700], glyph: "info" },
34
+ success: { bg: colors.emerald[50], border: colors.emerald[100], icon: colors.emerald[700], glyph: "circle-check" },
35
+ warning: { bg: colors.amber[50], border: colors.amber[100], icon: colors.amber[700], glyph: "triangle-alert" },
36
+ error: { bg: colors.red[50], border: colors.red[100], icon: colors.red[700], glyph: "circle-alert" },
31
37
  neutral: { bg: colors.zinc[50], border: colors.zinc[200], icon: colors.zinc[500], glyph: "info" },
32
38
  };
33
39
 
@@ -38,28 +44,54 @@ const TONES: Record<CalloutTone, ToneStyle> = {
38
44
  * prompt use `Alert`; for a one-word status use `Badge`. The tone is carried by
39
45
  * the icon + tint + border (never color alone — the icon and text stay legible),
40
46
  * so it reads on a glance and meets contrast on the light tint.
47
+ *
48
+ * COMPOUND — compose the body from the sub-components (like `Card`); this lets a
49
+ * callout carry rich content and inline actions, not just a string message:
50
+ *
51
+ * ```tsx
52
+ * <Callout tone="warning">
53
+ * <CalloutTitle>Phí chưa nhập</CalloutTitle>
54
+ * <CalloutText>Nhập trước khi xuất chứng từ, hoặc đặt = 0.</CalloutText>
55
+ * <CalloutActions>
56
+ * <Button title="Đặt = 0" onPress={fill} />
57
+ * </CalloutActions>
58
+ * </Callout>
59
+ * ```
41
60
  */
42
- export function Callout({ tone = "info", title, message, icon }: CalloutProps) {
61
+ export function Callout({ tone = "info", icon, children, testID }: CalloutProps) {
43
62
  const t = TONES[tone];
44
63
  const glyph = icon === null ? null : (icon ?? t.glyph);
45
64
  return (
46
65
  <View
47
66
  accessibilityRole={tone === "error" || tone === "warning" ? "alert" : undefined}
48
67
  style={[styles.container, { backgroundColor: t.bg, borderColor: t.border }]}
68
+ testID={testID}
49
69
  >
50
70
  {glyph ? <Icon name={glyph} size={18} color={t.icon} /> : null}
51
- <View style={styles.body}>
52
- {title ? (
53
- <Text size="sm" weight="semibold">
54
- {title}
55
- </Text>
56
- ) : null}
57
- <Text size="sm">{message}</Text>
58
- </View>
71
+ <View style={styles.body}>{children}</View>
59
72
  </View>
60
73
  );
61
74
  }
62
75
 
76
+ /** Bold heading at the top of a `Callout`. */
77
+ export function CalloutTitle({ children }: { children: ReactNode }) {
78
+ return (
79
+ <Text size="sm" weight="semibold">
80
+ {children}
81
+ </Text>
82
+ );
83
+ }
84
+
85
+ /** Message body of a `Callout` — default text color for legibility on the tint. */
86
+ export function CalloutText({ children }: { children: ReactNode }) {
87
+ return <Text size="sm">{children}</Text>;
88
+ }
89
+
90
+ /** A row of actions (e.g. buttons) at the foot of a `Callout`. */
91
+ export function CalloutActions({ children }: { children: ReactNode }) {
92
+ return <View style={styles.actions}>{children}</View>;
93
+ }
94
+
63
95
  export type { CalloutProps };
64
96
 
65
97
  const styles = StyleSheet.create({
@@ -72,4 +104,5 @@ const styles = StyleSheet.create({
72
104
  borderWidth: 1,
73
105
  },
74
106
  body: { flex: 1, gap: 2, paddingTop: 1 },
107
+ actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 6 },
75
108
  });
package/src/combobox.tsx CHANGED
@@ -54,6 +54,12 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
54
54
  /** Label for the free-entry row (default: `Add "<query>"`). Return null to
55
55
  * suppress it for a given query. */
56
56
  customOptionLabel?: (query: string) => string | null;
57
+ /** Where the free-entry "Create…" row sits relative to the matches. Default
58
+ * "bottom" (matches first, create as the fallback). "top" pins it above the
59
+ * results for a find-or-create field where the create affordance should stay
60
+ * visible; the default keyboard highlight still lands on the first MATCH, so
61
+ * Enter on a partial selects the existing record and never creates a duplicate. */
62
+ customOptionPlacement?: "top" | "bottom";
57
63
  /** Shown — under a heading — when the input is focused but empty. */
58
64
  recentOptions?: PickerOption<T, D>[];
59
65
  loading?: boolean;
@@ -111,6 +117,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
111
117
  getOptionDescription,
112
118
  allowCustom = false,
113
119
  customOptionLabel,
120
+ customOptionPlacement = "bottom",
114
121
  recentOptions,
115
122
  loading = false,
116
123
  searchDebounceMs = 200,
@@ -179,8 +186,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
179
186
 
180
187
  const list = useMemo(() => {
181
188
  const base = searching ? filtered : (recentOptions ?? []);
182
- return customRow ? [...base, customRow] : base;
183
- }, [searching, filtered, recentOptions, customRow]);
189
+ if (!customRow) return base;
190
+ return customOptionPlacement === "top" ? [customRow, ...base] : [...base, customRow];
191
+ }, [searching, filtered, recentOptions, customRow, customOptionPlacement]);
184
192
 
185
193
  const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
186
194
 
@@ -235,10 +243,18 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
235
243
  onActiveChange: scrollToIndex,
236
244
  });
237
245
 
246
+ // Default the keyboard highlight to the first real (non-custom, enabled)
247
+ // option, so a free-entry "Create…" row — wherever it sits — is never the
248
+ // Enter target while a match exists (Enter on a partial picks the match, not
249
+ // a duplicate). Only when the custom row is the sole option does it lead.
238
250
  const firstValue = list[0]?.value;
251
+ const defaultIndex = useMemo(() => {
252
+ const i = list.findIndex((o) => o.testID !== CUSTOM_VALUE && !o.disabled);
253
+ return i >= 0 ? i : 0;
254
+ }, [list]);
239
255
  useEffect(() => {
240
- setActiveIndex(0);
241
- }, [searching, list.length, firstValue, setActiveIndex]);
256
+ setActiveIndex(defaultIndex);
257
+ }, [searching, list.length, firstValue, defaultIndex, setActiveIndex]);
242
258
 
243
259
  const handleChangeText = useCallback(
244
260
  (text: string) => {
@@ -390,7 +406,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
390
406
  </Text>
391
407
  </View>
392
408
  ) : (
393
- <Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
409
+ <Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
394
410
  {label}
395
411
  </Text>
396
412
  );
@@ -401,7 +417,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
401
417
  testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
402
418
  role="option"
403
419
  accessibilityLabel={label}
404
- icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["400"]} /> : undefined}
420
+ icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined}
405
421
  title={title}
406
422
  focused={i === activeIndex}
407
423
  selected={!multi && !isCustom && single?.value === opt.value}
@@ -5,6 +5,8 @@ export interface FormDatePickerProps
5
5
  extends Omit<FormFieldProps, "style">,
6
6
  DatePickerProps {}
7
7
 
8
+ /** `FormField` wrapping a `DatePicker` — one labeled date field. (Omits `style`; for a grid cell
9
+ * use a bare `FormField style={half}` around `DatePicker`.) */
8
10
  export function FormDatePicker(props: FormDatePickerProps) {
9
11
  const { label, description, optional, optionalLabel, error, ...datePickerProps } = props;
10
12
 
@@ -1,6 +1,7 @@
1
1
  import { FormField, FormFieldProps } from "./form_field";
2
2
  import { Picker, PickerProps } from "./picker";
3
3
 
4
+ /** `FormField` wrapping a `Picker` — one labeled select. */
4
5
  export function FormPicker<T extends string, MULTI extends boolean = false>(
5
6
  props: PickerProps<T, MULTI> & FormFieldProps,
6
7
  ) {
@@ -7,6 +7,7 @@ import { Spacer } from "./spacer";
7
7
 
8
8
  export interface FormSwitchProps extends Omit<FormFieldProps, "optional">, SwitchProps {}
9
9
 
10
+ /** A labeled on/off toggle — a `Switch` + clickable label + optional `description`/`error`. */
10
11
  export function FormSwitch(props: FormSwitchProps) {
11
12
  const { label, description, error, value, onChange } = props;
12
13
  const labelId = useId();
@@ -4,6 +4,8 @@ import { TextInputField } from "./text_input_field";
4
4
 
5
5
  export interface FormTextInputProps extends Omit<FormFieldProps, "style">, TextInputProps {}
6
6
 
7
+ /** `FormField` (label / description / error / optional) wrapping a `TextInputField` — one labeled
8
+ * text field. For a 2-col grid cell, prefer a bare `FormField style={half}` around the input. */
7
9
  export function FormTextInput(props: FormTextInputProps) {
8
10
  const { label, description, optional, error, ...textInputProps } = props;
9
11
 
package/src/icon.tsx CHANGED
@@ -144,6 +144,7 @@ import PanelRightOpen from "lucide-react-native/dist/esm/icons/panel-right-open"
144
144
  import Paperclip from "lucide-react-native/dist/esm/icons/paperclip";
145
145
  import Pause from "lucide-react-native/dist/esm/icons/pause";
146
146
  import Pencil from "lucide-react-native/dist/esm/icons/pencil";
147
+ import Phone from "lucide-react-native/dist/esm/icons/phone";
147
148
  import Pin from "lucide-react-native/dist/esm/icons/pin";
148
149
  import PinOff from "lucide-react-native/dist/esm/icons/pin-off";
149
150
  import Play from "lucide-react-native/dist/esm/icons/play";
@@ -337,6 +338,7 @@ const iconComponents = {
337
338
  paperclip: Paperclip,
338
339
  pause: Pause,
339
340
  pencil: Pencil,
341
+ phone: Phone,
340
342
  pin: Pin,
341
343
  "pin-off": PinOff,
342
344
  play: Play,
@@ -9,7 +9,7 @@ interface IconButtonBase {
9
9
  ref?: Ref<View>;
10
10
  testID?: string;
11
11
  icon: IconName;
12
- color?: "none" | "secondary" | "white";
12
+ color?: "none" | "primary" | "secondary" | "white";
13
13
  /** `md` (28px, 18px glyph) for toolbars; `sm` (24px, 14px glyph) for
14
14
  * inline affordances sitting next to body/sm text — e.g. the ⓘ in a
15
15
  * CardHeader. Both keep a 40px touch target via hitSlop. */
@@ -71,7 +71,7 @@ export function IconButton(props: IconButtonProps) {
71
71
  <Icon
72
72
  size={size === "sm" ? 14 : 18}
73
73
  name={icon}
74
- color={iconColor || (color === "white" ? colors.white : colors.zinc[700])}
74
+ color={iconColor || (color === "white" || color === "primary" ? colors.white : colors.zinc[700])}
75
75
  />
76
76
  </PressableHighlight>
77
77
  );
@@ -97,6 +97,9 @@ const styles = StyleSheet.create({
97
97
  opacity: 0.3,
98
98
  },
99
99
  none: {},
100
+ primary: {
101
+ backgroundColor: colors.zinc[900],
102
+ },
100
103
  secondary: {
101
104
  backgroundColor: colors.zinc[100],
102
105
  },
@@ -0,0 +1,110 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Icon } from "./icon";
4
+ import { Text } from "./text";
5
+ import { colors } from "./colors";
6
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
+ import { DatePickerPanel } from "./date_picker";
8
+ import { formatDate } from "./format_date";
9
+ import { ActivityIndicator } from "./activity_indicator";
10
+ import { InlineEditView } from "./inline_edit";
11
+
12
+ export interface InlineDatePickerProps {
13
+ /** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
14
+ value: string | null;
15
+ onSave: (next: string) => void | Promise<void>;
16
+ /** "date" (default) or "datetime". */
17
+ format?: "date" | "datetime";
18
+ placeholder?: string;
19
+ /** BCP-47 locale for the calendar and the displayed date. */
20
+ locale?: string;
21
+ disabled?: boolean;
22
+ accessibilityLabel?: string;
23
+ }
24
+
25
+ /**
26
+ * An inline-editable date / datetime. The value reads as a formatted date;
27
+ * clicking it floats the calendar (`DatePickerPanel`) in a popover anchored to
28
+ * the view, so the row never changes height. The selection commits when the
29
+ * panel closes (immediately for a single date, after time entry for datetime);
30
+ * dismissing without a change reverts.
31
+ */
32
+ export function InlineDatePicker(props: InlineDatePickerProps) {
33
+ const { value, onSave, format = "date", placeholder, locale, disabled, accessibilityLabel } = props;
34
+ const [open, setOpen] = useState(false);
35
+ const [draft, setDraft] = useState<string | null>(value);
36
+ const [saving, setSaving] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+ // The latest panel value, read at close time (the draft state can be stale in
39
+ // the close handler's closure).
40
+ const draftRef = useRef<string | null>(value);
41
+
42
+ const commit = useCallback(async () => {
43
+ const next = draftRef.current;
44
+ if (next === value || !next) return;
45
+ setSaving(true);
46
+ setError(null);
47
+ try {
48
+ await onSave(next);
49
+ } catch (e) {
50
+ setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ }, [value, onSave]);
55
+
56
+ const onOpenChange = useCallback(
57
+ (next: boolean) => {
58
+ if (next) {
59
+ draftRef.current = value;
60
+ setDraft(value);
61
+ setOpen(true);
62
+ } else {
63
+ setOpen(false);
64
+ void commit();
65
+ }
66
+ },
67
+ [value, commit],
68
+ );
69
+
70
+ const display = formatDate(value, { format, locale, emptyLabel: "" });
71
+
72
+ return (
73
+ <View>
74
+ <Popover open={open && !disabled} onOpenChange={onOpenChange} side="bottom" align="start">
75
+ <PopoverTrigger>
76
+ <InlineEditView
77
+ display={display}
78
+ placeholder={placeholder}
79
+ disabled={disabled}
80
+ accessibilityLabel={accessibilityLabel}
81
+ trailing={
82
+ saving ? (
83
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
84
+ ) : (
85
+ <Icon name="calendar" size={18} color={colors.zinc[400]} />
86
+ )
87
+ }
88
+ />
89
+ </PopoverTrigger>
90
+ <PopoverContent>
91
+ <DatePickerPanel
92
+ value={draft}
93
+ onValueChange={(v) => {
94
+ draftRef.current = v;
95
+ setDraft(v);
96
+ }}
97
+ format={format}
98
+ locale={locale}
99
+ onRequestClose={() => setOpen(false)}
100
+ />
101
+ </PopoverContent>
102
+ </Popover>
103
+ {error ? (
104
+ <Text size="xs" color="danger" style={{ marginTop: 4 }}>
105
+ {error}
106
+ </Text>
107
+ ) : null}
108
+ </View>
109
+ );
110
+ }
@@ -0,0 +1,228 @@
1
+ import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Text } from "./text";
4
+ import { IconButton } from "./icon_button";
5
+ import { ActivityIndicator } from "./activity_indicator";
6
+ import { PressableHighlight } from "./pressable_highlight";
7
+ import { colors } from "./colors";
8
+ import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
9
+
10
+ /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
11
+ * The view box matches it — same height, padding, and a 1px transparent border
12
+ * — so swapping to the input on click never shifts the layout. */
13
+ export const INLINE_CONTROL_HEIGHT = 40;
14
+
15
+ export type InlineEditControls = "blur" | "buttons";
16
+
17
+ /**
18
+ * Drives an inline-editable field: a view ⇄ edit toggle, a draft buffer, and an
19
+ * async save with saving/error state. The typed `Inline*` inputs wire their
20
+ * control's `onBlur`/keydown to `commit()` / `cancel()`. Reuse this hook with
21
+ * `InlineEditFrame` to make any custom input inline-editable the same way.
22
+ */
23
+ export function useInlineEdit<T>(opts: {
24
+ value: T;
25
+ onSave: (next: T) => void | Promise<void>;
26
+ /** Equality used to skip a no-op save. Defaults to `===`. */
27
+ equals?: (a: T, b: T) => boolean;
28
+ }) {
29
+ const { value, onSave, equals } = opts;
30
+ const [editing, setEditing] = useState(false);
31
+ const [draft, setDraft] = useState<T>(value);
32
+ const [saving, setSaving] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ // Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
35
+ // false; any trailing call — e.g. the blur that fires as Escape unmounts the
36
+ // input — is then a no-op, so the field commits/cancels exactly once.
37
+ const active = useRef(false);
38
+
39
+ const begin = useCallback(() => {
40
+ setDraft(value);
41
+ setError(null);
42
+ active.current = true;
43
+ setEditing(true);
44
+ }, [value]);
45
+
46
+ const cancel = useCallback(() => {
47
+ if (!active.current) return;
48
+ active.current = false;
49
+ setEditing(false);
50
+ setError(null);
51
+ }, []);
52
+
53
+ const commit = useCallback(
54
+ async (next?: T) => {
55
+ if (!active.current) return;
56
+ const candidate = next === undefined ? draft : next;
57
+ const unchanged = equals ? equals(candidate, value) : candidate === value;
58
+ if (unchanged) {
59
+ active.current = false;
60
+ setEditing(false);
61
+ return;
62
+ }
63
+ active.current = false;
64
+ setSaving(true);
65
+ setError(null);
66
+ try {
67
+ await onSave(candidate);
68
+ setEditing(false);
69
+ } catch (e) {
70
+ // Stay in edit mode so the entry isn't lost — show the error, re-arm.
71
+ setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
72
+ active.current = true;
73
+ } finally {
74
+ setSaving(false);
75
+ }
76
+ },
77
+ [draft, value, onSave, equals],
78
+ );
79
+
80
+ return { editing, draft, setDraft, saving, error, begin, cancel, commit };
81
+ }
82
+
83
+ interface InlineEditFrameProps {
84
+ editing: boolean;
85
+ /** The formatted current value, shown in view mode. Empty → placeholder. */
86
+ display: string;
87
+ placeholder?: string;
88
+ /** Enter edit mode (the view is pressed). */
89
+ onBegin: () => void;
90
+ /** The edit-mode control (rendered only while editing). */
91
+ children: React.ReactNode;
92
+ /** "blur" (default) commits on blur; "buttons" shows ✓/✕ and blur is inert. */
93
+ controls?: InlineEditControls;
94
+ onCommit?: () => void;
95
+ onCancel?: () => void;
96
+ saving?: boolean;
97
+ error?: string | null;
98
+ disabled?: boolean;
99
+ /** Accessible name for the view button when there's no enclosing FormField. */
100
+ accessibilityLabel?: string;
101
+ }
102
+
103
+ interface InlineEditViewProps {
104
+ /** The formatted current value. Empty → placeholder. */
105
+ display: string;
106
+ placeholder?: string;
107
+ onPress?: () => void;
108
+ disabled?: boolean;
109
+ accessibilityLabel?: string;
110
+ /** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving. */
111
+ trailing?: ReactNode;
112
+ ref?: Ref<View>;
113
+ }
114
+
115
+ /**
116
+ * The view-mode box of an inline-editable field: the value as plain text in a
117
+ * `PressableHighlight` (hover tints it; the pointer cursor signals it's
118
+ * editable), at the kit's control height with a transparent border — so
119
+ * swapping to an input, or floating a dropdown above it, never shifts the
120
+ * layout. Used by `InlineEditFrame`, and as the overlay trigger for the
121
+ * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
122
+ */
123
+ export function InlineEditView(props: InlineEditViewProps) {
124
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, ref } = props;
125
+ return (
126
+ <PressableHighlight
127
+ ref={ref}
128
+ disabled={disabled}
129
+ onPress={onPress}
130
+ accessibilityRole="button"
131
+ accessibilityLabel={accessibilityLabel}
132
+ userSelect="none"
133
+ style={styles.view}
134
+ >
135
+ <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
136
+ {display || placeholder || "—"}
137
+ </Text>
138
+ {trailing != null ? trailing : null}
139
+ </PressableHighlight>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * The shared shell of an inline-editable INPUT (text, number). View mode is an
145
+ * `InlineEditView`; on press it swaps to the input at the same height — no
146
+ * layout shift — plus the optional save/cancel controls. Compose it with
147
+ * `useInlineEdit`. (Overlay-based editors — select, date — use `InlineEditView`
148
+ * directly as a `Popover` trigger instead.)
149
+ */
150
+ export function InlineEditFrame(props: InlineEditFrameProps) {
151
+ const {
152
+ editing,
153
+ display,
154
+ placeholder,
155
+ onBegin,
156
+ children,
157
+ controls = "blur",
158
+ onCommit,
159
+ onCancel,
160
+ saving,
161
+ error,
162
+ disabled,
163
+ accessibilityLabel,
164
+ } = props;
165
+
166
+ if (!editing) {
167
+ return (
168
+ <InlineEditView
169
+ display={display}
170
+ placeholder={placeholder}
171
+ onPress={onBegin}
172
+ disabled={disabled}
173
+ accessibilityLabel={accessibilityLabel}
174
+ />
175
+ );
176
+ }
177
+
178
+ return (
179
+ <View>
180
+ <View style={styles.editRow}>
181
+ <View style={styles.editControl}>
182
+ {children}
183
+ {/* The saving spinner floats inside the input's right edge — it must
184
+ never push or resize the control (no layout shift). */}
185
+ {saving ? (
186
+ <View style={styles.savingOverlay} pointerEvents="none">
187
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
188
+ </View>
189
+ ) : null}
190
+ </View>
191
+ {controls === "buttons" ? (
192
+ <View style={styles.buttons}>
193
+ <IconButton icon="check" color="primary" size="sm" tooltip="Save" onPress={onCommit} disabled={saving} />
194
+ <IconButton icon="x" color="secondary" size="sm" tooltip="Cancel" onPress={onCancel} disabled={saving} />
195
+ </View>
196
+ ) : null}
197
+ </View>
198
+ {error ? (
199
+ <Text size="xs" color="danger" style={styles.error}>
200
+ {error}
201
+ </Text>
202
+ ) : null}
203
+ </View>
204
+ );
205
+ }
206
+
207
+ // The view text reuses the input's own canonical metrics (font, size, spacing)
208
+ // so the value reads identically and occupies the same box in both modes.
209
+ const viewTextStyle = [getInputTextStyle(), { flex: 1, fontFamily: fontFamilyRegular, letterSpacing: -0.4 }];
210
+
211
+ const styles = StyleSheet.create({
212
+ view: {
213
+ minHeight: INLINE_CONTROL_HEIGHT,
214
+ borderRadius: 8,
215
+ borderWidth: 1,
216
+ borderColor: "transparent",
217
+ paddingHorizontal: 8,
218
+ flexDirection: "row",
219
+ alignItems: "center",
220
+ gap: 6,
221
+ },
222
+ placeholder: { color: colors.zinc[400] },
223
+ editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
224
+ editControl: { flex: 1, position: "relative" },
225
+ savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
226
+ buttons: { flexDirection: "row", gap: 6, height: INLINE_CONTROL_HEIGHT, alignItems: "center" },
227
+ error: { marginTop: 4 },
228
+ });
@@ -0,0 +1,70 @@
1
+ import { useCallback } from "react";
2
+ import type { KeyboardEvent } from "react";
3
+ import { NumberInput } from "./number_input";
4
+ import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
+
6
+ export interface InlineNumberInputProps {
7
+ value: number | null;
8
+ onSave: (next: number | null) => void | Promise<void>;
9
+ /** Format the value for view mode (e.g. currency, units). Default: the plain
10
+ * number, or empty → placeholder when null. */
11
+ format?: (value: number | null) => string;
12
+ placeholder?: string;
13
+ min?: number;
14
+ max?: number;
15
+ controls?: InlineEditControls;
16
+ disabled?: boolean;
17
+ accessibilityLabel?: string;
18
+ }
19
+
20
+ /**
21
+ * An inline-editable numeric value — the `InlineTextInput` pattern over
22
+ * `NumberInput`. View mode can format the number (currency, units); edit mode is
23
+ * the bare numeric field at the same height, so the form never reflows.
24
+ */
25
+ export function InlineNumberInput(props: InlineNumberInputProps) {
26
+ const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel } = props;
27
+ const edit = useInlineEdit<number | null>({ value, onSave });
28
+
29
+ const onKeyDown = useCallback(
30
+ (e: KeyboardEvent<HTMLInputElement>) => {
31
+ if (e.key === "Escape") edit.cancel();
32
+ else if (e.key === "Enter") void edit.commit();
33
+ },
34
+ [edit],
35
+ );
36
+
37
+ const onBlur = useCallback(() => {
38
+ if (controls === "buttons") return;
39
+ void edit.commit();
40
+ }, [controls, edit]);
41
+
42
+ const display = format ? format(value) : value == null ? "" : String(value);
43
+
44
+ return (
45
+ <InlineEditFrame
46
+ editing={edit.editing}
47
+ display={display}
48
+ placeholder={placeholder}
49
+ onBegin={edit.begin}
50
+ controls={controls}
51
+ onCommit={() => void edit.commit()}
52
+ onCancel={edit.cancel}
53
+ saving={edit.saving}
54
+ error={edit.error}
55
+ disabled={disabled}
56
+ accessibilityLabel={accessibilityLabel}
57
+ >
58
+ <NumberInput
59
+ value={edit.draft}
60
+ onValueChange={edit.setDraft}
61
+ onBlur={onBlur}
62
+ onKeyDown={onKeyDown}
63
+ autoFocus
64
+ min={min}
65
+ max={max}
66
+ accessibilityLabel={accessibilityLabel}
67
+ />
68
+ </InlineEditFrame>
69
+ );
70
+ }