@lotics/ui 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +171 -68
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_item_list.tsx +1015 -124
  7. package/examples/tpl_pick.tsx +3 -3
  8. package/examples/tpl_pivot.tsx +1 -1
  9. package/examples/tpl_record.tsx +1354 -0
  10. package/examples/tpl_report.tsx +7 -7
  11. package/examples/tpl_rollup.tsx +6 -6
  12. package/examples/tpl_shifts.tsx +2 -2
  13. package/examples/tpl_statements.tsx +221 -0
  14. package/examples/tpl_stock.tsx +7 -7
  15. package/examples/tpl_task_board.tsx +16 -13
  16. package/examples/tpl_tasks.tsx +15 -28
  17. package/examples/tpl_tower.tsx +2 -2
  18. package/package.json +8 -1
  19. package/src/capture_row.tsx +59 -0
  20. package/src/checklist.tsx +104 -0
  21. package/src/chip.tsx +12 -3
  22. package/src/detail_row.tsx +137 -10
  23. package/src/inline_date_picker.tsx +8 -3
  24. package/src/inline_edit.tsx +40 -10
  25. package/src/inline_member_select.tsx +3 -0
  26. package/src/inline_number_input.tsx +5 -2
  27. package/src/inline_select.tsx +8 -3
  28. package/src/inline_tag_select.tsx +140 -0
  29. package/src/inline_text_input.tsx +5 -2
  30. package/src/inline_time_picker.tsx +5 -2
  31. package/src/ledger.tsx +220 -0
  32. package/src/locale.tsx +21 -0
  33. package/src/progress_bar.tsx +32 -1
  34. package/src/record_summary.tsx +101 -0
  35. package/src/section_heading.tsx +16 -8
  36. package/src/suggestion_chip.tsx +47 -0
  37. package/src/use_section_nav.test.ts +69 -0
  38. package/src/use_section_nav.ts +59 -0
  39. package/examples/tpl_billing.tsx +0 -344
  40. package/examples/tpl_detail.tsx +0 -232
  41. package/examples/tpl_directory.tsx +0 -260
  42. package/examples/tpl_intake.tsx +0 -206
  43. package/examples/tpl_order.tsx +0 -482
  44. package/examples/tpl_quick.tsx +0 -211
  45. package/examples/tpl_record_plain.tsx +0 -259
  46. package/examples/tpl_settings.tsx +0 -178
  47. package/examples/tpl_timeline.tsx +0 -244
  48. package/examples/tpl_wizard.tsx +0 -223
@@ -0,0 +1,59 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Button } from "./button";
3
+ import { colors } from "./colors";
4
+ import { useLoticsLocale } from "./locale";
5
+ import { TextInputField } from "./text_input_field";
6
+
7
+ export interface CaptureRowProps {
8
+ /** The draft text — controlled by the host list. */
9
+ value: string;
10
+ onChangeText: (next: string) => void;
11
+ /** Commit the draft (Enter, or the Save button that appears on type). The
12
+ * host trims/validates and clears `value`. */
13
+ onSubmit: () => void;
14
+ /** The invitation ("Add a task…"). */
15
+ placeholder?: string;
16
+ /** Save button title. Defaults to the locale's inline Save. */
17
+ saveLabel?: string;
18
+ accessibilityLabel: string;
19
+ }
20
+
21
+ /**
22
+ * The add-an-item row that closes an editable list: a dashed empty ring where
23
+ * the item's leading control will be, a borderless input on the same inset as
24
+ * the item titles above it, and a primary Save that appears once there's text.
25
+ * Enter commits too. One shape wherever a list grows in place — task
26
+ * checklists, simple item lists — so the affordance reads the same everywhere.
27
+ */
28
+ export function CaptureRow(props: CaptureRowProps) {
29
+ const { value, onChangeText, onSubmit, placeholder, saveLabel, accessibilityLabel } = props;
30
+ const labels = useLoticsLocale().inline;
31
+ return (
32
+ <View style={styles.row}>
33
+ <View style={styles.emptyRing} />
34
+ <View style={styles.field}>
35
+ <View style={styles.grow}>
36
+ <TextInputField
37
+ value={value}
38
+ onChangeText={onChangeText}
39
+ onSubmitEditing={onSubmit}
40
+ placeholder={placeholder}
41
+ style={styles.input}
42
+ accessibilityLabel={accessibilityLabel}
43
+ />
44
+ </View>
45
+ {value.trim() !== "" ? <Button title={saveLabel ?? labels.save} color="primary" onPress={onSubmit} /> : null}
46
+ </View>
47
+ </View>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
53
+ emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
54
+ field: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8 },
55
+ grow: { flex: 1 },
56
+ // Borderless, matching an inline title's resting inset so the invitation
57
+ // lines up with the item titles above.
58
+ input: { borderWidth: 0, backgroundColor: "transparent", paddingVertical: 0 },
59
+ });
@@ -0,0 +1,104 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { ActionMenu, type ActionMenuItem } from "./action_menu";
4
+
5
+ // The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
6
+ // (row height, ring/title alignment, one trailing column width) while the
7
+ // CONTENT stays composed: a `CheckCircle` (live or read-only) in `control`,
8
+ // a struck `InlineTextInput` or plain `Text` as the title, an
9
+ // `InlineMemberSelect` or nothing in `trailing`, and `menu` for the row's
10
+ // ⋯ options (Delete lives BEHIND the menu — indirection that prevents
11
+ // accidental destructive taps). Suggested tasks are NOT rows: offer the record
12
+ // type's commons as `SuggestionChip`s under the list (tap = materialize,
13
+ // ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
14
+ // list as its add affordance. There is deliberately NO monolithic Task
15
+ // component — richer task-management rows (expand affordances, tag/clip meta,
16
+ // board cards) compose their own anatomy directly.
17
+ //
18
+ // <Checklist trailingWidth={140}>
19
+ // <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
20
+ // menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
21
+ // accessibilityLabel: "Task options: …" }}>
22
+ // <InlineTextInput background="transparent" struck={done} … />
23
+ // </ChecklistRow>
24
+ // <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
25
+ // <CaptureRow … />
26
+ // </Checklist>
27
+
28
+ interface ChecklistContextValue {
29
+ trailingWidth?: number;
30
+ }
31
+
32
+ const ChecklistContext = createContext<ChecklistContextValue>({});
33
+
34
+ export interface ChecklistProps {
35
+ /** Fixed width of every row's trailing cell (an assignee select) — set it
36
+ * once so the column lines up; omit when rows carry no trailing. */
37
+ trailingWidth?: number;
38
+ children: ReactNode;
39
+ }
40
+
41
+ export function Checklist(props: ChecklistProps) {
42
+ const { trailingWidth, children } = props;
43
+ return (
44
+ <ChecklistContext.Provider value={{ trailingWidth }}>
45
+ <View style={styles.list}>{children}</View>
46
+ </ChecklistContext.Provider>
47
+ );
48
+ }
49
+
50
+ interface ChecklistRowBaseProps {
51
+ /** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
52
+ control: ReactNode;
53
+ /** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
54
+ children: ReactNode;
55
+ /** The row's ⋯ options menu (destructive items last, `danger: true`) —
56
+ * Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
57
+ * destroy a task. Name the task in the label ("Task options: Book the
58
+ * carrier"). The ⋯ column aligns only when every row in the list carries
59
+ * a menu — keep its presence uniform per list. */
60
+ menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
61
+ }
62
+
63
+ /** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
64
+ export type ChecklistRowProps = ChecklistRowBaseProps &
65
+ (
66
+ | {
67
+ /** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
68
+ trailing?: ReactNode;
69
+ meta?: never;
70
+ }
71
+ | {
72
+ /** SECOND line under the title (indented past the ring) for a NARROW
73
+ * surface — a drawer or popover checklist: put the assignee/due
74
+ * editors here so the title keeps the full width and stays readable. */
75
+ meta?: ReactNode;
76
+ trailing?: never;
77
+ }
78
+ );
79
+
80
+ /** One task line: control · title (flex) · the aligned trailing cell · ⋯,
81
+ * with an optional indented `meta` line below for narrow surfaces. */
82
+ export function ChecklistRow(props: ChecklistRowProps) {
83
+ const { control, children, trailing, menu, meta } = props;
84
+ const { trailingWidth } = useContext(ChecklistContext);
85
+ return (
86
+ <View>
87
+ <View style={styles.row}>
88
+ {control}
89
+ <View style={styles.title}>{children}</View>
90
+ {trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
91
+ {menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
92
+ </View>
93
+ {meta != null ? <View style={styles.meta}>{meta}</View> : null}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ const styles = StyleSheet.create({
99
+ list: { gap: 4 },
100
+ row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
101
+ title: { flex: 1 },
102
+ // Indent past the ring (20) + the row gap (12) so meta aligns with the title.
103
+ meta: { paddingLeft: 32, paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
104
+ });
package/src/chip.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Ref } from "react";
2
2
  import { IconButton } from "./icon_button";
3
+ import { useLoticsLocale } from "./locale";
3
4
  import { PressableHighlight } from "./pressable_highlight";
4
5
  import { chipSurfaceStyle } from "./control_surface";
5
6
  import { StyleSheet, View } from "react-native";
@@ -8,19 +9,27 @@ interface ChipProps {
8
9
  testID?: string;
9
10
  children: React.ReactNode;
10
11
  onPress?: () => void;
12
+ /** Accessible name of the press target. The pressable pill announces as a
13
+ * button either way — its name derives from the children when they are
14
+ * self-describing text; pass this when they aren't (icon-led content). */
15
+ accessibilityLabel?: string;
11
16
  onDismiss?: () => void;
12
17
  dismissTooltip?: string;
13
18
  ref?: Ref<View>;
14
19
  }
15
20
 
16
21
  export function Chip(props: ChipProps) {
17
- const { testID, children, onPress, onDismiss, dismissTooltip, ref } = props;
22
+ const { testID, children, onPress, accessibilityLabel, onDismiss, dismissTooltip, ref } = props;
23
+ const labels = useLoticsLocale().chip;
18
24
 
19
25
  return (
20
26
  <View ref={ref}>
21
27
  {onPress ? (
22
28
  <PressableHighlight
23
- focusRing testID={testID}
29
+ focusRing
30
+ testID={testID}
31
+ accessibilityRole="button"
32
+ accessibilityLabel={accessibilityLabel}
24
33
  onPress={onPress}
25
34
  style={(state) => [chipSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
26
35
  >
@@ -34,7 +43,7 @@ export function Chip(props: ChipProps) {
34
43
  <IconButton
35
44
  icon="x"
36
45
  tooltip={dismissTooltip}
37
- accessibilityLabel={dismissTooltip ?? "Remove"}
46
+ accessibilityLabel={dismissTooltip ?? labels.remove}
38
47
  onPress={onDismiss}
39
48
  testID={testID ? `${testID}-remove` : undefined}
40
49
  />
@@ -1,24 +1,107 @@
1
- import { ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
1
+ import { createContext, ReactNode, useContext, useState } from "react";
2
+ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
3
4
  import { Text } from "./text";
4
5
 
6
+ interface DetailTableColumns {
7
+ labelWidth: number;
8
+ trailingWidth?: number;
9
+ minHeight: number;
10
+ /** Container too narrow for the side-by-side columns — rows render STACKED
11
+ * (label above a full-width value row). */
12
+ stacked: boolean;
13
+ }
14
+
15
+ const DetailTableContext = createContext<DetailTableColumns | null>(null);
16
+
17
+ /** The value cell must keep at least this width before the table gives up its
18
+ * side-by-side columns and stacks — below it, editors get crushed. */
19
+ const MIN_VALUE_WIDTH = 160;
20
+
21
+ export interface DetailTableProps {
22
+ /** Label column width (px). Default 130. */
23
+ labelWidth?: number;
24
+ /** Reserved width (px) for the trailing column. Set it when ANY row carries a
25
+ * trailing action/unit/badge: EVERY row then reserves the column, so all
26
+ * value cells share ONE width and the trailing items align at one x — the
27
+ * table look. Omit when no row has a trailing (values fill to the edge). */
28
+ trailingWidth?: number;
29
+ /** Min row height. Default 40 (`INLINE_CONTROL_HEIGHT`) so editor, static,
30
+ * and custom rows all sit on the inline-control baseline grid. */
31
+ minHeight?: number;
32
+ /** The usable width the value cell must keep before the table STACKS
33
+ * (default 160). Raise it when a cell holds MORE than one editor (an
34
+ * amount + method pair) — side-by-side columns that technically fit can
35
+ * still crush a multi-editor cell. */
36
+ minValueWidth?: number;
37
+ /** `DetailRow`s (each inherits the table's columns; row props override). */
38
+ children: ReactNode;
39
+ style?: StyleProp<ViewStyle>;
40
+ }
41
+
42
+ /**
43
+ * The column grid for a stack of `DetailRow`s — label · value · trailing laid
44
+ * out like a TABLE: one label width, one value width, one trailing width, set
45
+ * ONCE on the parent instead of repeated (and drifting) per row. Rows without a
46
+ * trailing still reserve the trailing column, so a `Copy` action on one row
47
+ * never makes its editor narrower than its neighbours'.
48
+ *
49
+ * <DetailTable labelWidth={130} trailingWidth={96}>
50
+ * <DetailRow label="Reference" trailing={<Button title="Copy" … />}>
51
+ * <InlineTextInput … />
52
+ * </DetailRow>
53
+ * <DetailRow label="Priority"><InlineSelect … /></DetailRow>
54
+ * </DetailTable>
55
+ *
56
+ * It owns the 6px row gap (zinc-50 editor chips need visible separation) and
57
+ * the 40px row height of the inline-control grid.
58
+ *
59
+ * RESPONSIVE: the table measures its own container (not the viewport — a table
60
+ * inside a narrow Drawer on a wide screen must adapt too). When the columns
61
+ * would crush the value cell below a usable width, every row STACKS: label
62
+ * above a full-width value row (trailing stays beside the value). No prop —
63
+ * the switch is automatic.
64
+ */
65
+ export function DetailTable(props: DetailTableProps) {
66
+ const { labelWidth = 130, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
67
+ const [width, setWidth] = useState<number | null>(null);
68
+ const stacked =
69
+ width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
70
+ return (
71
+ <DetailTableContext.Provider value={{ labelWidth, trailingWidth, minHeight, stacked }}>
72
+ <View
73
+ onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
74
+ // Measure-then-REVEAL: the unmeasured first frame renders invisible
75
+ // (opacity 0), so the first PAINT the user sees is already in the
76
+ // right mode — no table→stacked reshuffle as a drawer opens.
77
+ style={[stacked ? styles.tableStacked : styles.table, width == null ? styles.unmeasured : null, style]}
78
+ >
79
+ {children}
80
+ </View>
81
+ </DetailTableContext.Provider>
82
+ );
83
+ }
84
+
5
85
  export interface DetailRowProps {
6
86
  /** The field label — rendered `sm muted`. */
7
87
  label: ReactNode;
8
88
  /** The value — any node (a `Text`, a `Badge`, a stack). */
9
89
  children: ReactNode;
10
90
  /** An optional right-side slot, rendered as the LAST child of the row (after
11
- * `children`) — a small action `Button`, a status `Badge`, a unit label, an
12
- * `IconButton`. In form mode (`labelWidth` set) the value column already fills
13
- * the row, so the trailing pins to the right edge automatically. */
91
+ * `children`) — a small action `Button`, a status `Badge`, an `IconButton`.
92
+ * NOT for units: "kg" / "₫" belong IN the value (`InlineNumberInput format`).
93
+ * In form mode (`labelWidth` set) the value column already fills
94
+ * the row, so the trailing pins to the right edge automatically. Inside a
95
+ * `DetailTable` with `trailingWidth` it sits in the reserved trailing column. */
14
96
  trailing?: ReactNode;
15
97
  /** Fixed label-column width (px) to align labels in a left column — for a peek
16
98
  * or form-like stack. Omit for a spread row: the label takes the slack and the
17
- * value sits at the right edge (the default drawer-detail look). */
99
+ * value sits at the right edge (the default drawer-detail look). Inside a
100
+ * `DetailTable` the table's `labelWidth` applies; this overrides per row. */
18
101
  labelWidth?: number;
19
102
  /** Label text size — `xs` for a compact peek, `sm` (default) for a drawer. */
20
103
  labelSize?: "xs" | "sm";
21
- /** Min row height. Default 28. */
104
+ /** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
22
105
  minHeight?: number;
23
106
  }
24
107
 
@@ -27,23 +110,61 @@ export interface DetailRowProps {
27
110
  * `PressableRow` / `TableRow`). Spread by default (muted label left, value pushed
28
111
  * to the right edge); pass `labelWidth` to align labels in a fixed left column —
29
112
  * the value column then FILLS the rest of the row, so a stack of inline editors
30
- * all span the same width (none jumps wider on edit).
113
+ * all span the same width (none jumps wider on edit). For a stack of rows, wrap
114
+ * them in a `DetailTable`: the label/trailing column widths live ONCE on the
115
+ * parent and every row aligns.
31
116
  */
32
117
  export function DetailRow(props: DetailRowProps) {
33
- const { label, children, trailing, labelWidth, labelSize = "sm", minHeight = 28 } = props;
118
+ const table = useContext(DetailTableContext);
119
+ const { label, children, trailing, labelSize = "sm" } = props;
120
+ const labelWidth = props.labelWidth ?? table?.labelWidth;
121
+ const minHeight = props.minHeight ?? table?.minHeight ?? 28;
34
122
  const form = labelWidth != null;
123
+ if (table?.stacked) {
124
+ // Stacked mode wears the FORM grammar: the label renders exactly like a
125
+ // `FormField` label (medium, default ink), so a narrow record surface
126
+ // reads as one vocabulary with forms. The COMPONENTS stay separate — a
127
+ // FormField wraps a draft control validated and committed together, an
128
+ // inline editor self-persists — only the look converges.
129
+ return (
130
+ <View style={styles.stackedRow}>
131
+ <Text weight="medium" numberOfLines={1}>
132
+ {label}
133
+ </Text>
134
+ <View style={[styles.stackedValueRow, { minHeight }]}>
135
+ <View style={styles.value}>{children}</View>
136
+ {trailing != null ? trailing : null}
137
+ </View>
138
+ </View>
139
+ );
140
+ }
35
141
  return (
36
142
  <View style={[styles.row, { minHeight }]}>
37
143
  <Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
38
144
  {label}
39
145
  </Text>
40
146
  {form ? <View style={styles.value}>{children}</View> : children}
41
- {trailing != null ? trailing : null}
147
+ {table?.trailingWidth != null ? (
148
+ <View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
149
+ ) : trailing != null ? (
150
+ trailing
151
+ ) : null}
42
152
  </View>
43
153
  );
44
154
  }
45
155
 
46
156
  const styles = StyleSheet.create({
157
+ // 6px between rows — the zinc-50 editor chips need visible separation.
158
+ table: { gap: 6 },
159
+ // Stacked (narrow) mode: label-over-value blocks need more air between rows.
160
+ tableStacked: { gap: 14 },
161
+ unmeasured: { opacity: 0 },
162
+ stackedRow: { gap: 4 },
163
+ stackedValueRow: {
164
+ flexDirection: "row",
165
+ alignItems: "center",
166
+ gap: 12,
167
+ },
47
168
  row: {
48
169
  flexDirection: "row",
49
170
  alignItems: "center",
@@ -56,4 +177,10 @@ const styles = StyleSheet.create({
56
177
  // child fills it edge-to-edge. A status Badge or action is NOT a value — pass
57
178
  // it as `trailing` (pins right); one placed here would stretch full-width.
58
179
  value: { flex: 1 },
180
+ // The reserved trailing column: every row renders it (empty or not) so value
181
+ // cells share one width and trailing items align at one x, like a table.
182
+ trailingCell: {
183
+ flexDirection: "row",
184
+ alignItems: "center",
185
+ },
59
186
  });
@@ -7,7 +7,8 @@ import { DatePickerPanel } from "./date_picker";
7
7
  import { isoHasTime } from "./date_picker_value";
8
8
  import { formatDate } from "./format_date";
9
9
  import { ActivityIndicator } from "./activity_indicator";
10
- import { InlineEditView } from "./inline_edit";
10
+ import { type InlineEditBackground, InlineEditView } from "./inline_edit";
11
+ import { useLoticsLocale } from "./locale";
11
12
 
12
13
  export interface InlineDatePickerProps {
13
14
  /** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
@@ -23,6 +24,8 @@ export interface InlineDatePickerProps {
23
24
  locale?: string;
24
25
  disabled?: boolean;
25
26
  accessibilityLabel?: string;
27
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
28
+ background?: InlineEditBackground;
26
29
  }
27
30
 
28
31
  /**
@@ -33,7 +36,8 @@ export interface InlineDatePickerProps {
33
36
  * dismissing without a change reverts.
34
37
  */
35
38
  export function InlineDatePicker(props: InlineDatePickerProps) {
36
- const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel } = props;
39
+ const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
40
+ const labels = useLoticsLocale().inline;
37
41
  const [open, setOpen] = useState(false);
38
42
  const [draft, setDraft] = useState<string | null>(value);
39
43
  const [saving, setSaving] = useState(false);
@@ -50,7 +54,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
50
54
  try {
51
55
  await onSave(next);
52
56
  } catch (e) {
53
- setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
57
+ setError(e instanceof Error && e.message ? e.message : labels.saveError);
54
58
  } finally {
55
59
  setSaving(false);
56
60
  }
@@ -81,6 +85,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
81
85
  <Popover open={open && !disabled} onOpenChange={onOpenChange} side="bottom" align="start">
82
86
  <PopoverTrigger>
83
87
  <InlineEditView
88
+ background={background}
84
89
  display={display}
85
90
  placeholder={placeholder}
86
91
  disabled={disabled}
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
2
2
  import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
5
+ import { useLoticsLocale } from "./locale";
5
6
  import { ActivityIndicator } from "./activity_indicator";
6
7
  import { FocusRingPressable } from "./focus_ring_pressable";
7
8
  import { colors } from "./colors";
@@ -28,6 +29,7 @@ export function useInlineEdit<T>(opts: {
28
29
  equals?: (a: T, b: T) => boolean;
29
30
  }) {
30
31
  const { value, onSave, equals } = opts;
32
+ const labels = useLoticsLocale().inline;
31
33
  const [editing, setEditing] = useState(false);
32
34
  const [draft, setDraft] = useState<T>(value);
33
35
  const [saving, setSaving] = useState(false);
@@ -69,7 +71,7 @@ export function useInlineEdit<T>(opts: {
69
71
  setEditing(false);
70
72
  } catch (e) {
71
73
  // 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.");
74
+ setError(e instanceof Error && e.message ? e.message : labels.saveError);
73
75
  active.current = true;
74
76
  } finally {
75
77
  setSaving(false);
@@ -81,6 +83,13 @@ export function useInlineEdit<T>(opts: {
81
83
  return { editing, draft, setDraft, saving, error, begin, cancel, commit };
82
84
  }
83
85
 
86
+ /** The resting surface of an inline editor. "tint" (default) is the zinc-50
87
+ * chip — THE editability affordance on surfaces that MIX editable and static
88
+ * values. "transparent" drops it for DENSE, uniformly-editable surfaces (a
89
+ * task list/board where every cell edits — the chip repeated everywhere is
90
+ * noise, and hover/focus still reveal the input). */
91
+ export type InlineEditBackground = "tint" | "transparent";
92
+
84
93
  interface InlineEditFrameProps {
85
94
  editing: boolean;
86
95
  /** The formatted current value, shown in view mode. Empty → placeholder. */
@@ -101,6 +110,8 @@ interface InlineEditFrameProps {
101
110
  accessibilityLabel?: string;
102
111
  /** Strike + mute the resting value (a completed item that stays editable). */
103
112
  struck?: boolean;
113
+ /** Resting surface — see {@link InlineEditBackground}. Default "tint". */
114
+ background?: InlineEditBackground;
104
115
  }
105
116
 
106
117
  interface InlineEditViewProps {
@@ -119,20 +130,23 @@ interface InlineEditViewProps {
119
130
  active?: boolean;
120
131
  /** Strike + mute the resting value (a completed/superseded item that stays editable). */
121
132
  struck?: boolean;
133
+ /** Resting surface — see {@link InlineEditBackground}. Default "tint". */
134
+ background?: InlineEditBackground;
122
135
  ref?: Ref<View>;
123
136
  }
124
137
 
125
138
  /**
126
139
  * The view-mode box of an inline-editable field: the value as plain text in a
127
140
  * `FocusRingPressable` (an input, so it hovers via its BORDER — the transparent
128
- * edge reveals on hover, never a grey wash; the pointer cursor signals it's
129
- * editable), at the kit's control height with a transparent border so
141
+ * edge reveals on hover, never a grey wash; the cursor stays the default arrow —
142
+ * the zinc-50 chip + hover-border are the affordance, a pointer would read as a
143
+ * button), at the kit's control height with a transparent border — so
130
144
  * swapping to an input, or floating a dropdown above it, never shifts the
131
145
  * layout. Used by `InlineEditFrame`, and as the overlay trigger for the
132
146
  * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
133
147
  */
134
148
  export function InlineEditView(props: InlineEditViewProps) {
135
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, ref } = props;
149
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, background = "tint", ref } = props;
136
150
  // Stop the press here so an inline editor nested in a pressable row (a task
137
151
  // row that expands on press) edits the field instead of triggering the row.
138
152
  const handlePress = onPress
@@ -152,7 +166,7 @@ export function InlineEditView(props: InlineEditViewProps) {
152
166
  // An inline-edit field is an input — it hovers via its BORDER, not a grey
153
167
  // wash: the transparent edge reveals on hover (the kit's hover-border),
154
168
  // then fills + rings when open. Suppressed while open (the ring leads).
155
- style={(state) => [styles.view, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
169
+ style={(state) => [styles.view, (background === "transparent" || disabled) && styles.viewTransparent, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
156
170
  >
157
171
  {typeof display === "string" ? (
158
172
  <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
@@ -174,6 +188,7 @@ export function InlineEditView(props: InlineEditViewProps) {
174
188
  * directly as a `Popover` trigger instead.)
175
189
  */
176
190
  export function InlineEditFrame(props: InlineEditFrameProps) {
191
+ const labels = useLoticsLocale().inline;
177
192
  const {
178
193
  editing,
179
194
  display,
@@ -188,11 +203,13 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
188
203
  disabled,
189
204
  accessibilityLabel,
190
205
  struck,
206
+ background,
191
207
  } = props;
192
208
 
193
209
  if (!editing) {
194
210
  return (
195
211
  <InlineEditView
212
+ background={background}
196
213
  display={display}
197
214
  placeholder={placeholder}
198
215
  onPress={onBegin}
@@ -218,8 +235,8 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
218
235
  </View>
219
236
  {controls === "buttons" ? (
220
237
  <View style={styles.buttons}>
221
- <IconButton icon="check" color="primary" size="sm" tooltip="Save" onPress={onCommit} disabled={saving} />
222
- <IconButton icon="x" color="secondary" size="sm" tooltip="Cancel" onPress={onCancel} disabled={saving} />
238
+ <IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
239
+ <IconButton icon="x" color="secondary" size="sm" tooltip={labels.cancel} onPress={onCancel} disabled={saving} />
223
240
  </View>
224
241
  ) : null}
225
242
  </View>
@@ -247,24 +264,37 @@ export const inlineValueTextStyle: TextStyle = {
247
264
  const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
248
265
 
249
266
  const styles = StyleSheet.create({
267
+ // At REST an editor sits on a zinc-50 chip — THE editability affordance: a
268
+ // read-only `InlineStatic` stays flat, so editable and static values are
269
+ // distinguishable without hovering (they were pixel-identical before, and
270
+ // users could not discover what was editable).
250
271
  view: {
251
272
  minHeight: INLINE_CONTROL_HEIGHT,
252
273
  borderRadius: CONTROL_RADIUS,
253
274
  borderWidth: 1,
254
275
  borderColor: "transparent",
276
+ backgroundColor: colors.zinc[50],
255
277
  paddingHorizontal: 8,
256
278
  flexDirection: "row",
257
279
  alignItems: "center",
258
280
  gap: 6,
281
+ // An inline editor is an INPUT, not a button — keep the default arrow
282
+ // cursor (the chip + hover-border carry the affordance; a pointer reads
283
+ // as a button press). Overrides the Pressable's pointer default.
284
+ cursor: "auto",
259
285
  },
286
+ // The transparent opt-out — dense, uniformly-editable surfaces.
287
+ viewTransparent: { backgroundColor: "transparent" },
260
288
  // Open (popover showing): the transparent edge fills in to the kit's 1px border
261
- // under the 2px active ring — identical to a focused input.
289
+ // under the 2px active ring — identical to a focused input; the chip clears to
290
+ // the input's white surface.
262
291
  viewActive: {
263
292
  borderColor: colors.border,
293
+ backgroundColor: colors.white,
264
294
  boxShadow: FOCUS_RING,
265
295
  },
266
- // Hover (not open): reveal the kit hover-border on the transparent edge — the
267
- // input-family affordance, no grey wash.
296
+ // Hover (not open): reveal the kit hover-border on the chip — the input-family
297
+ // affordance.
268
298
  viewHovered: {
269
299
  borderColor: HOVER_BORDER,
270
300
  },
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useMemo } from "react";
2
+ import type { InlineEditBackground } from "./inline_edit";
2
3
  import { InlineSelect } from "./inline_select";
3
4
  import { MemberChip } from "./member_chip";
4
5
  import type { MemberSelectMember } from "./member_select";
@@ -18,6 +19,8 @@ interface InlineMemberSelectProps {
18
19
  placeholder?: string;
19
20
  disabled?: boolean;
20
21
  accessibilityLabel?: string;
22
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
23
+ background?: InlineEditBackground;
21
24
  /** Show an in-menu search box to filter the roster; default false. */
22
25
  searchable?: boolean;
23
26
  }
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { KeyboardEvent } from "react";
3
3
  import { NumberInput } from "./number_input";
4
- import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
4
+ import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
5
 
6
6
  export interface InlineNumberInputProps {
7
7
  value: number | null;
@@ -15,6 +15,8 @@ export interface InlineNumberInputProps {
15
15
  controls?: InlineEditControls;
16
16
  disabled?: boolean;
17
17
  accessibilityLabel?: string;
18
+ /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
19
+ background?: InlineEditBackground;
18
20
  }
19
21
 
20
22
  /**
@@ -23,7 +25,7 @@ export interface InlineNumberInputProps {
23
25
  * the bare numeric field at the same height, so the form never reflows.
24
26
  */
25
27
  export function InlineNumberInput(props: InlineNumberInputProps) {
26
- const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel } = props;
28
+ const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel , background } = props;
27
29
  const edit = useInlineEdit<number | null>({ value, onSave });
28
30
 
29
31
  const onKeyDown = useCallback(
@@ -43,6 +45,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
43
45
 
44
46
  return (
45
47
  <InlineEditFrame
48
+ background={background}
46
49
  editing={edit.editing}
47
50
  display={display}
48
51
  placeholder={placeholder}