@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.
- package/AGENTS.md +352 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_billing.tsx +344 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +12 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +33 -8
- package/src/control_surface.ts +8 -0
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/index.css +6 -3
- package/src/inline_date_picker.tsx +111 -0
- package/src/inline_edit.tsx +238 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +92 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/link.tsx +32 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/picker.tsx +4 -1
- package/src/popover.tsx +10 -1
- package/src/pressable_row.tsx +4 -1
- package/src/radio_picker.tsx +3 -1
- package/src/section_heading.tsx +43 -29
- package/src/segmented_control.tsx +3 -2
- package/src/tabs.tsx +4 -2
- package/src/tag_input.tsx +202 -0
- package/src/text.tsx +1 -1
- package/src/time_picker.tsx +15 -3
- 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
|
-
|
|
183
|
-
|
|
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(
|
|
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={
|
|
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}
|
|
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["
|
|
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,
|
package/src/control_surface.ts
CHANGED
|
@@ -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
|
package/src/form_date_picker.tsx
CHANGED
|
@@ -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
|
|
package/src/form_picker.tsx
CHANGED
|
@@ -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
|
) {
|
package/src/form_switch.tsx
CHANGED
|
@@ -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();
|
package/src/form_text_input.tsx
CHANGED
|
@@ -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,
|
package/src/icon_button.tsx
CHANGED
|
@@ -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
|
-
/*
|
|
340
|
-
|
|
341
|
-
|
|
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
|
+
}
|