@lotics/ui 3.6.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.
- package/AGENTS.md +323 -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_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 +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +22 -6
- 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/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -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/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
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
|
-
|
|
183
|
-
|
|
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(
|
|
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}
|
|
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["
|
|
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}
|
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
|
},
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useCallback, 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 { PickerMenu } from "./picker_menu";
|
|
8
|
+
import type { PickerOption } from "./picker";
|
|
9
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
+
import { InlineEditView } from "./inline_edit";
|
|
11
|
+
|
|
12
|
+
export interface InlineSelectProps<T extends string> {
|
|
13
|
+
value: T | null;
|
|
14
|
+
onSave: (next: T) => void | Promise<void>;
|
|
15
|
+
options: PickerOption<T>[];
|
|
16
|
+
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
17
|
+
* Omit for a plain label list — both render through the same `PickerMenu`. */
|
|
18
|
+
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
accessibilityLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An inline-editable single-select. The value reads as plain text; clicking it
|
|
26
|
+
* floats the option list (a `PickerMenu`) in a popover anchored to the view, so
|
|
27
|
+
* the row never changes height. Picking commits; dismissing reverts. Pass
|
|
28
|
+
* `renderOptionContent` for rich options, or omit it for a plain list — the
|
|
29
|
+
* standard and custom-rendered pickers are both supported here.
|
|
30
|
+
*/
|
|
31
|
+
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
32
|
+
const { value, onSave, options, renderOptionContent, placeholder, disabled, accessibilityLabel } = props;
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const [saving, setSaving] = useState(false);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
const selected = options.find((o) => o.value === value);
|
|
38
|
+
|
|
39
|
+
const pick = useCallback(
|
|
40
|
+
async (next: T) => {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
if (next === value) return;
|
|
43
|
+
setSaving(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
await onSave(next);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
|
|
49
|
+
} finally {
|
|
50
|
+
setSaving(false);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[value, onSave],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View>
|
|
58
|
+
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
59
|
+
<PopoverTrigger>
|
|
60
|
+
<InlineEditView
|
|
61
|
+
display={selected?.label ?? ""}
|
|
62
|
+
placeholder={placeholder}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
accessibilityLabel={accessibilityLabel}
|
|
65
|
+
trailing={
|
|
66
|
+
saving ? (
|
|
67
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
68
|
+
) : (
|
|
69
|
+
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
</PopoverTrigger>
|
|
74
|
+
<PopoverContent>
|
|
75
|
+
<PickerMenu
|
|
76
|
+
options={options}
|
|
77
|
+
value={value}
|
|
78
|
+
onValueChange={(next) => void pick(next)}
|
|
79
|
+
onRequestClose={() => setOpen(false)}
|
|
80
|
+
renderOptionContent={renderOptionContent}
|
|
81
|
+
/>
|
|
82
|
+
</PopoverContent>
|
|
83
|
+
</Popover>
|
|
84
|
+
{error ? (
|
|
85
|
+
<Text size="xs" color="danger" style={{ marginTop: 4 }}>
|
|
86
|
+
{error}
|
|
87
|
+
</Text>
|
|
88
|
+
) : null}
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|