@lotics/ui 3.6.0 → 4.1.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 (65) hide show
  1. package/AGENTS.md +352 -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_billing.tsx +344 -0
  8. package/examples/tpl_calendar.tsx +288 -0
  9. package/examples/tpl_callsheet.tsx +481 -0
  10. package/examples/tpl_convert.tsx +490 -0
  11. package/examples/tpl_crm_desk.tsx +541 -0
  12. package/examples/tpl_dashboard.tsx +554 -0
  13. package/examples/tpl_detail.tsx +232 -0
  14. package/examples/tpl_directory.tsx +263 -0
  15. package/examples/tpl_dispatch.tsx +289 -0
  16. package/examples/tpl_dossier.tsx +431 -0
  17. package/examples/tpl_intake.tsx +206 -0
  18. package/examples/tpl_inventory.tsx +299 -0
  19. package/examples/tpl_order.tsx +483 -0
  20. package/examples/tpl_pick.tsx +240 -0
  21. package/examples/tpl_quick.tsx +210 -0
  22. package/examples/tpl_reconcile.tsx +275 -0
  23. package/examples/tpl_record.tsx +301 -0
  24. package/examples/tpl_record_plain.tsx +154 -0
  25. package/examples/tpl_rollup.tsx +300 -0
  26. package/examples/tpl_run.tsx +235 -0
  27. package/examples/tpl_settings.tsx +178 -0
  28. package/examples/tpl_shifts.tsx +421 -0
  29. package/examples/tpl_stock.tsx +387 -0
  30. package/examples/tpl_timeline.tsx +244 -0
  31. package/examples/tpl_tower.tsx +356 -0
  32. package/examples/tpl_wizard.tsx +223 -0
  33. package/package.json +12 -2
  34. package/src/bar_chart.tsx +5 -0
  35. package/src/combobox.tsx +33 -8
  36. package/src/control_surface.ts +8 -0
  37. package/src/form_date_picker.tsx +2 -0
  38. package/src/form_picker.tsx +1 -0
  39. package/src/form_switch.tsx +1 -0
  40. package/src/form_text_input.tsx +2 -0
  41. package/src/icon.tsx +2 -0
  42. package/src/icon_button.tsx +5 -2
  43. package/src/index.css +6 -3
  44. package/src/inline_date_picker.tsx +111 -0
  45. package/src/inline_edit.tsx +238 -0
  46. package/src/inline_number_input.tsx +70 -0
  47. package/src/inline_select.tsx +92 -0
  48. package/src/inline_text_input.tsx +71 -0
  49. package/src/inline_time_picker.tsx +64 -0
  50. package/src/line_chart.tsx +4 -0
  51. package/src/link.tsx +32 -0
  52. package/src/list_item.tsx +5 -0
  53. package/src/number_input.tsx +12 -1
  54. package/src/page_content.tsx +5 -0
  55. package/src/picker.tsx +4 -1
  56. package/src/popover.tsx +10 -1
  57. package/src/pressable_row.tsx +4 -1
  58. package/src/radio_picker.tsx +3 -1
  59. package/src/section_heading.tsx +43 -29
  60. package/src/segmented_control.tsx +3 -2
  61. package/src/tabs.tsx +4 -2
  62. package/src/tag_input.tsx +202 -0
  63. package/src/text.tsx +1 -1
  64. package/src/time_picker.tsx +15 -3
  65. package/src/tooltip.tsx +19 -0
package/src/combobox.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "react-native";
10
10
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
11
11
  import { colors } from "./colors";
12
+ import { ACTIVE_RING } from "./control_surface";
12
13
  import { Text } from "./text";
13
14
  import { Icon, type IconName } from "./icon";
14
15
  import { TextInputField } from "./text_input_field";
@@ -54,6 +55,12 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
54
55
  /** Label for the free-entry row (default: `Add "<query>"`). Return null to
55
56
  * suppress it for a given query. */
56
57
  customOptionLabel?: (query: string) => string | null;
58
+ /** Where the free-entry "Create…" row sits relative to the matches. Default
59
+ * "bottom" (matches first, create as the fallback). "top" pins it above the
60
+ * results for a find-or-create field where the create affordance should stay
61
+ * visible; the default keyboard highlight still lands on the first MATCH, so
62
+ * Enter on a partial selects the existing record and never creates a duplicate. */
63
+ customOptionPlacement?: "top" | "bottom";
57
64
  /** Shown — under a heading — when the input is focused but empty. */
58
65
  recentOptions?: PickerOption<T, D>[];
59
66
  loading?: boolean;
@@ -111,6 +118,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
111
118
  getOptionDescription,
112
119
  allowCustom = false,
113
120
  customOptionLabel,
121
+ customOptionPlacement = "bottom",
114
122
  recentOptions,
115
123
  loading = false,
116
124
  searchDebounceMs = 200,
@@ -179,8 +187,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
179
187
 
180
188
  const list = useMemo(() => {
181
189
  const base = searching ? filtered : (recentOptions ?? []);
182
- return customRow ? [...base, customRow] : base;
183
- }, [searching, filtered, recentOptions, customRow]);
190
+ if (!customRow) return base;
191
+ return customOptionPlacement === "top" ? [customRow, ...base] : [...base, customRow];
192
+ }, [searching, filtered, recentOptions, customRow, customOptionPlacement]);
184
193
 
185
194
  const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
186
195
 
@@ -235,10 +244,18 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
235
244
  onActiveChange: scrollToIndex,
236
245
  });
237
246
 
247
+ // Default the keyboard highlight to the first real (non-custom, enabled)
248
+ // option, so a free-entry "Create…" row — wherever it sits — is never the
249
+ // Enter target while a match exists (Enter on a partial picks the match, not
250
+ // a duplicate). Only when the custom row is the sole option does it lead.
238
251
  const firstValue = list[0]?.value;
252
+ const defaultIndex = useMemo(() => {
253
+ const i = list.findIndex((o) => o.testID !== CUSTOM_VALUE && !o.disabled);
254
+ return i >= 0 ? i : 0;
255
+ }, [list]);
239
256
  useEffect(() => {
240
- setActiveIndex(0);
241
- }, [searching, list.length, firstValue, setActiveIndex]);
257
+ setActiveIndex(defaultIndex);
258
+ }, [searching, list.length, firstValue, defaultIndex, setActiveIndex]);
242
259
 
243
260
  const handleChangeText = useCallback(
244
261
  (text: string) => {
@@ -273,7 +290,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
273
290
 
274
291
  return (
275
292
  <View style={style}>
276
- <View ref={triggerRef} style={multi ? styles.multiBox : undefined}>
293
+ <View ref={triggerRef} style={multi ? [styles.multiBox, open && styles.openRing] : undefined}>
277
294
  {multi
278
295
  ? chips.map((chip) => (
279
296
  <View key={chip.value} style={styles.chip}>
@@ -321,7 +338,12 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
321
338
  autoFocus={autoFocus}
322
339
  autoCapitalize="none"
323
340
  autoCorrect={false}
324
- style={multi ? styles.multiInput : showChevron ? styles.selectInput : undefined}
341
+ style={[
342
+ multi ? styles.multiInput : showChevron ? styles.selectInput : undefined,
343
+ // Single-select wears the open ring on the input (which holds the
344
+ // border); multi wears it on the wrapper above (the input is borderless).
345
+ !multi && open ? styles.openRing : undefined,
346
+ ]}
325
347
  role="combobox"
326
348
  aria-expanded={showList}
327
349
  aria-controls={listboxId}
@@ -390,7 +412,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
390
412
  </Text>
391
413
  </View>
392
414
  ) : (
393
- <Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
415
+ <Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
394
416
  {label}
395
417
  </Text>
396
418
  );
@@ -401,7 +423,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
401
423
  testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
402
424
  role="option"
403
425
  accessibilityLabel={label}
404
- icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["400"]} /> : undefined}
426
+ icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined}
405
427
  title={title}
406
428
  focused={i === activeIndex}
407
429
  selected={!multi && !isCustom && single?.value === opt.value}
@@ -439,6 +461,9 @@ const styles = StyleSheet.create({
439
461
  borderWidth: 0,
440
462
  height: 28,
441
463
  },
464
+ openRing: {
465
+ boxShadow: ACTIVE_RING,
466
+ },
442
467
  // Select mode: room on the right for the trailing chevron.
443
468
  selectInput: {
444
469
  paddingRight: 32,
@@ -5,6 +5,14 @@ import { colors } from "./colors";
5
5
  * buttons) aligns to it so a toolbar row reads as one band. */
6
6
  export const CONTROL_HEIGHT = 40;
7
7
 
8
+ /** The active/open edge for an interactive control — a 2px zinc-900 ring flush
9
+ * against the box (offset 0), MIRRORING the keyboard `:focus-visible` outline
10
+ * (index.css). A mouse-opened popover trigger (Picker / Combobox / InlineSelect /
11
+ * InlineDatePicker) never receives `:focus-visible`, so apply this on its open
12
+ * state to read identically to a focused input — a lone 1px border looks thin
13
+ * beside the kit's focus outline and selected-pill ring. */
14
+ export const ACTIVE_RING = `0 0 0 2px ${colors.zinc[900]}`;
15
+
8
16
  /**
9
17
  * THE pill-surface contract — the single definition of the bordered, white,
10
18
  * rounded interactive surface shared by `ChipGroup` chips and `PillButton` (and
@@ -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
  },
package/src/index.css CHANGED
@@ -336,9 +336,12 @@ html {
336
336
  color: var(--color-zinc-900);
337
337
  }
338
338
 
339
- /* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
340
- so pointer/touch interactions stay visually unchanged. The ring sits flush
341
- against the element's border (offset 0, no gap) and, being an outline, never
339
+ /* Focus ring — keyboard-driven only (`:focus-visible`), so pointer/touch
340
+ interactions stay clutter-free (no stray ring stacked on a clicked button,
341
+ tab, or segment that already shows its own state). A SELECT shows its own
342
+ active edge on a mouse-open via `ACTIVE_RING` (control_surface.ts) — that, not
343
+ this rule, is what rings the combobox/picker on a pointer open. The ring sits
344
+ flush against the border (offset 0, no gap) and, being an outline, never
342
345
  reflows layout. */
343
346
  :focus-visible {
344
347
  outline: 2px solid var(--color-zinc-900);
@@ -0,0 +1,111 @@
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
+ active={open && !disabled}
81
+ accessibilityLabel={accessibilityLabel}
82
+ trailing={
83
+ saving ? (
84
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
85
+ ) : (
86
+ <Icon name="calendar" size={18} color={colors.zinc[400]} />
87
+ )
88
+ }
89
+ />
90
+ </PopoverTrigger>
91
+ <PopoverContent>
92
+ <DatePickerPanel
93
+ value={draft}
94
+ onValueChange={(v) => {
95
+ draftRef.current = v;
96
+ setDraft(v);
97
+ }}
98
+ format={format}
99
+ locale={locale}
100
+ onRequestClose={() => setOpen(false)}
101
+ />
102
+ </PopoverContent>
103
+ </Popover>
104
+ {error ? (
105
+ <Text size="xs" color="danger" style={{ marginTop: 4 }}>
106
+ {error}
107
+ </Text>
108
+ ) : null}
109
+ </View>
110
+ );
111
+ }
@@ -0,0 +1,238 @@
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 { ACTIVE_RING } from "./control_surface";
9
+ import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
10
+
11
+ /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
12
+ * The view box matches it — same height, padding, and a 1px transparent border
13
+ * — so swapping to the input on click never shifts the layout. */
14
+ export const INLINE_CONTROL_HEIGHT = 40;
15
+
16
+ export type InlineEditControls = "blur" | "buttons";
17
+
18
+ /**
19
+ * Drives an inline-editable field: a view ⇄ edit toggle, a draft buffer, and an
20
+ * async save with saving/error state. The typed `Inline*` inputs wire their
21
+ * control's `onBlur`/keydown to `commit()` / `cancel()`. Reuse this hook with
22
+ * `InlineEditFrame` to make any custom input inline-editable the same way.
23
+ */
24
+ export function useInlineEdit<T>(opts: {
25
+ value: T;
26
+ onSave: (next: T) => void | Promise<void>;
27
+ /** Equality used to skip a no-op save. Defaults to `===`. */
28
+ equals?: (a: T, b: T) => boolean;
29
+ }) {
30
+ const { value, onSave, equals } = opts;
31
+ const [editing, setEditing] = useState(false);
32
+ const [draft, setDraft] = useState<T>(value);
33
+ const [saving, setSaving] = useState(false);
34
+ const [error, setError] = useState<string | null>(null);
35
+ // Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
36
+ // false; any trailing call — e.g. the blur that fires as Escape unmounts the
37
+ // input — is then a no-op, so the field commits/cancels exactly once.
38
+ const active = useRef(false);
39
+
40
+ const begin = useCallback(() => {
41
+ setDraft(value);
42
+ setError(null);
43
+ active.current = true;
44
+ setEditing(true);
45
+ }, [value]);
46
+
47
+ const cancel = useCallback(() => {
48
+ if (!active.current) return;
49
+ active.current = false;
50
+ setEditing(false);
51
+ setError(null);
52
+ }, []);
53
+
54
+ const commit = useCallback(
55
+ async (next?: T) => {
56
+ if (!active.current) return;
57
+ const candidate = next === undefined ? draft : next;
58
+ const unchanged = equals ? equals(candidate, value) : candidate === value;
59
+ if (unchanged) {
60
+ active.current = false;
61
+ setEditing(false);
62
+ return;
63
+ }
64
+ active.current = false;
65
+ setSaving(true);
66
+ setError(null);
67
+ try {
68
+ await onSave(candidate);
69
+ setEditing(false);
70
+ } catch (e) {
71
+ // Stay in edit mode so the entry isn't lost — show the error, re-arm.
72
+ setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
73
+ active.current = true;
74
+ } finally {
75
+ setSaving(false);
76
+ }
77
+ },
78
+ [draft, value, onSave, equals],
79
+ );
80
+
81
+ return { editing, draft, setDraft, saving, error, begin, cancel, commit };
82
+ }
83
+
84
+ interface InlineEditFrameProps {
85
+ editing: boolean;
86
+ /** The formatted current value, shown in view mode. Empty → placeholder. */
87
+ display: string;
88
+ placeholder?: string;
89
+ /** Enter edit mode (the view is pressed). */
90
+ onBegin: () => void;
91
+ /** The edit-mode control (rendered only while editing). */
92
+ children: React.ReactNode;
93
+ /** "blur" (default) commits on blur; "buttons" shows ✓/✕ and blur is inert. */
94
+ controls?: InlineEditControls;
95
+ onCommit?: () => void;
96
+ onCancel?: () => void;
97
+ saving?: boolean;
98
+ error?: string | null;
99
+ disabled?: boolean;
100
+ /** Accessible name for the view button when there's no enclosing FormField. */
101
+ accessibilityLabel?: string;
102
+ }
103
+
104
+ interface InlineEditViewProps {
105
+ /** The formatted current value. Empty → placeholder. */
106
+ display: string;
107
+ placeholder?: string;
108
+ onPress?: () => void;
109
+ disabled?: boolean;
110
+ accessibilityLabel?: string;
111
+ /** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving. */
112
+ trailing?: ReactNode;
113
+ /** True while the field's popover (select/date) is open: wears the 2px active
114
+ * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
115
+ active?: boolean;
116
+ ref?: Ref<View>;
117
+ }
118
+
119
+ /**
120
+ * The view-mode box of an inline-editable field: the value as plain text in a
121
+ * `PressableHighlight` (hover tints it; the pointer cursor signals it's
122
+ * editable), at the kit's control height with a transparent border — so
123
+ * swapping to an input, or floating a dropdown above it, never shifts the
124
+ * layout. Used by `InlineEditFrame`, and as the overlay trigger for the
125
+ * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
126
+ */
127
+ export function InlineEditView(props: InlineEditViewProps) {
128
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, ref } = props;
129
+ return (
130
+ <PressableHighlight
131
+ ref={ref}
132
+ disabled={disabled}
133
+ onPress={onPress}
134
+ accessibilityRole="button"
135
+ accessibilityLabel={accessibilityLabel}
136
+ userSelect="none"
137
+ style={[styles.view, active && styles.viewActive]}
138
+ >
139
+ <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
140
+ {display || placeholder || "—"}
141
+ </Text>
142
+ {trailing != null ? trailing : null}
143
+ </PressableHighlight>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * The shared shell of an inline-editable INPUT (text, number). View mode is an
149
+ * `InlineEditView`; on press it swaps to the input at the same height — no
150
+ * layout shift — plus the optional save/cancel controls. Compose it with
151
+ * `useInlineEdit`. (Overlay-based editors — select, date — use `InlineEditView`
152
+ * directly as a `Popover` trigger instead.)
153
+ */
154
+ export function InlineEditFrame(props: InlineEditFrameProps) {
155
+ const {
156
+ editing,
157
+ display,
158
+ placeholder,
159
+ onBegin,
160
+ children,
161
+ controls = "blur",
162
+ onCommit,
163
+ onCancel,
164
+ saving,
165
+ error,
166
+ disabled,
167
+ accessibilityLabel,
168
+ } = props;
169
+
170
+ if (!editing) {
171
+ return (
172
+ <InlineEditView
173
+ display={display}
174
+ placeholder={placeholder}
175
+ onPress={onBegin}
176
+ disabled={disabled}
177
+ accessibilityLabel={accessibilityLabel}
178
+ />
179
+ );
180
+ }
181
+
182
+ return (
183
+ <View>
184
+ <View style={styles.editRow}>
185
+ <View style={styles.editControl}>
186
+ {children}
187
+ {/* The saving spinner floats inside the input's right edge — it must
188
+ never push or resize the control (no layout shift). */}
189
+ {saving ? (
190
+ <View style={styles.savingOverlay} pointerEvents="none">
191
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
192
+ </View>
193
+ ) : null}
194
+ </View>
195
+ {controls === "buttons" ? (
196
+ <View style={styles.buttons}>
197
+ <IconButton icon="check" color="primary" size="sm" tooltip="Save" onPress={onCommit} disabled={saving} />
198
+ <IconButton icon="x" color="secondary" size="sm" tooltip="Cancel" onPress={onCancel} disabled={saving} />
199
+ </View>
200
+ ) : null}
201
+ </View>
202
+ {error ? (
203
+ <Text size="xs" color="danger" style={styles.error}>
204
+ {error}
205
+ </Text>
206
+ ) : null}
207
+ </View>
208
+ );
209
+ }
210
+
211
+ // The view text reuses the input's own canonical metrics (font, size, spacing)
212
+ // so the value reads identically and occupies the same box in both modes.
213
+ const viewTextStyle = [getInputTextStyle(), { flex: 1, fontFamily: fontFamilyRegular, letterSpacing: -0.4 }];
214
+
215
+ const styles = StyleSheet.create({
216
+ view: {
217
+ minHeight: INLINE_CONTROL_HEIGHT,
218
+ borderRadius: 8,
219
+ borderWidth: 1,
220
+ borderColor: "transparent",
221
+ paddingHorizontal: 8,
222
+ flexDirection: "row",
223
+ alignItems: "center",
224
+ gap: 6,
225
+ },
226
+ // Open (popover showing): the transparent edge fills in to the kit's 1px border
227
+ // under the 2px active ring — identical to a focused input.
228
+ viewActive: {
229
+ borderColor: colors.border,
230
+ boxShadow: ACTIVE_RING,
231
+ },
232
+ placeholder: { color: colors.zinc[400] },
233
+ editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
234
+ editControl: { flex: 1, position: "relative" },
235
+ savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
236
+ buttons: { flexDirection: "row", gap: 6, height: INLINE_CONTROL_HEIGHT, alignItems: "center" },
237
+ error: { marginTop: 4 },
238
+ });
@@ -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
+ }