@lotics/ui 22.3.0 → 23.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +22 -22
  4. package/docs/catalog.md +131 -99
  5. package/docs/composition.md +134 -42
  6. package/docs/data_entry.md +67 -31
  7. package/docs/templates.md +86 -68
  8. package/examples/tpl_allocate.tsx +5 -5
  9. package/examples/tpl_attendance.tsx +2 -2
  10. package/examples/tpl_calendar.tsx +6 -6
  11. package/examples/tpl_dashboard.tsx +9 -9
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_item_list.tsx +64 -37
  14. package/examples/tpl_lookup.tsx +5 -5
  15. package/examples/tpl_pick.tsx +6 -6
  16. package/examples/tpl_pivot.tsx +3 -3
  17. package/examples/tpl_record.tsx +919 -611
  18. package/examples/tpl_report.tsx +2 -2
  19. package/examples/tpl_rollup.tsx +5 -5
  20. package/examples/tpl_shifts.tsx +5 -5
  21. package/examples/tpl_statements.tsx +7 -7
  22. package/examples/tpl_stock.tsx +2 -2
  23. package/examples/tpl_task_board.tsx +42 -26
  24. package/examples/tpl_tower.tsx +6 -6
  25. package/package.json +4 -3
  26. package/src/agent_run.tsx +4 -4
  27. package/src/breakdown.tsx +1 -1
  28. package/src/calendar/calendar_view.tsx +1 -1
  29. package/src/change_review.tsx +9 -8
  30. package/src/chip_group.tsx +12 -2
  31. package/src/choice_list.tsx +2 -2
  32. package/src/confidence.tsx +2 -2
  33. package/src/data_grid.tsx +1 -1
  34. package/src/detail_row.tsx +50 -58
  35. package/src/file_dropzone.tsx +1 -1
  36. package/src/file_gallery_modal.tsx +3 -3
  37. package/src/file_row.tsx +29 -4
  38. package/src/file_rows.tsx +1 -1
  39. package/src/finding.tsx +4 -4
  40. package/src/form_field.tsx +1 -1
  41. package/src/format_date.ts +2 -2
  42. package/src/heatmap.tsx +1 -1
  43. package/src/inline_button.tsx +84 -0
  44. package/src/inline_date_picker.tsx +17 -10
  45. package/src/inline_edit.tsx +341 -60
  46. package/src/inline_member_select.tsx +8 -3
  47. package/src/inline_number_input.tsx +11 -4
  48. package/src/inline_select.tsx +26 -13
  49. package/src/inline_text_input.tsx +27 -6
  50. package/src/inline_time_picker.tsx +10 -4
  51. package/src/input_metrics.ts +17 -0
  52. package/src/ledger.tsx +2 -2
  53. package/src/locale.tsx +7 -3
  54. package/src/matrix.tsx +1 -1
  55. package/src/number_input.tsx +18 -7
  56. package/src/pipeline.tsx +1 -1
  57. package/src/popover.tsx +1 -1
  58. package/src/press_door.tsx +1 -1
  59. package/src/pressable_highlight.tsx +1 -1
  60. package/src/progress_bar.tsx +3 -3
  61. package/src/record_summary.tsx +2 -2
  62. package/src/result_header.tsx +2 -2
  63. package/src/sequence.tsx +170 -0
  64. package/src/share_or_download.ts +2 -2
  65. package/src/step_progress.tsx +7 -5
  66. package/src/stepper.tsx +1 -1
  67. package/src/task.tsx +6 -6
  68. package/src/text_input_field.tsx +21 -4
  69. package/src/text_utils.ts +7 -1
  70. package/src/linked_record_box.tsx +0 -157
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from "react";
1
+ import { useRef, useState, type ReactNode } from "react";
2
2
  import { View, StyleSheet } from "react-native";
3
3
  import { Icon } from "./icon";
4
4
  import { Text } from "./text";
@@ -24,9 +24,14 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
24
24
  * as-is (e.g. an avatar "add" ghost for an `avatarOnly` member cell). */
25
25
  placeholder?: string | ReactNode;
26
26
  disabled?: boolean;
27
- accessibilityLabel?: string;
28
- /** Form field (default) or grid cell — see {@link InlineEditVariant}. */
27
+ /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
29
28
  variant?: InlineEditVariant;
29
+ /** Verbs on the field's surface — an `InlineButton` Copy, Open… See
30
+ * `InlineEditView.actions`: they sit INSIDE the field, in the same place in
31
+ * every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
32
+ * has nothing to act on) or the field resizes as the value fills. */
33
+ actions?: ReactNode;
34
+ accessibilityLabel?: string;
30
35
  /** Show an in-menu search box; default false. */
31
36
  searchable?: boolean;
32
37
  /** Offer a "create" row when the query matches no option — picking it commits the
@@ -67,30 +72,36 @@ function InlineSelectShell(props: {
67
72
  display: string | ReactNode;
68
73
  placeholder?: string | ReactNode;
69
74
  accessibilityLabel?: string;
70
- variant?: InlineEditVariant;
71
75
  saving: boolean;
72
76
  error: string | null;
73
77
  children: ReactNode;
78
+ variant?: InlineEditVariant;
79
+ actions?: ReactNode;
74
80
  }) {
81
+ // The popover INHERITS THE TRIGGER'S WIDTH, so it has to measure the FIELD —
82
+ // with verbs on the surface the value region inside it is narrower, and
83
+ // anchoring there would open a list too small for the row it belongs to.
84
+ const anchorRef = useRef<View>(null);
75
85
  return (
76
86
  <View>
77
- <Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} side="bottom" align="start" inheritTriggerWidth>
87
+ <Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} triggerRef={anchorRef} side="bottom" align="start" inheritTriggerWidth>
78
88
  <PopoverTrigger>
79
89
  <InlineEditView
90
+ anchorRef={anchorRef}
91
+ actions={props.actions}
80
92
  variant={props.variant}
81
93
  display={props.display}
82
94
  placeholder={props.placeholder}
83
95
  disabled={props.disabled}
84
96
  active={props.open && !props.disabled}
85
97
  accessibilityLabel={props.accessibilityLabel}
86
- // A `cell` reads value-first: it drops the resting chevron (the column
87
- // header + the uniformly-editable grid are the affordance), like the date
88
- // cell drops its calendar glyph. A `form` field keeps the chevron. The
89
- // saving spinner shows in both.
98
+ // The resting chevron says "this opens a list" the same promise on
99
+ // every surface, now that a field looks the same everywhere. The
100
+ // saving spinner replaces it while a write is in flight.
90
101
  trailing={
91
102
  props.saving ? (
92
103
  <ActivityIndicator size={16} color={colors.zinc[400]} />
93
- ) : props.variant === "cell" ? undefined : (
104
+ ) : (
94
105
  <Icon name="chevron-down" size={18} color={colors.zinc[400]} />
95
106
  )
96
107
  }
@@ -108,7 +119,7 @@ function InlineSelectShell(props: {
108
119
  }
109
120
 
110
121
  export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
111
- const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, variant, searchable = false, allowCustom = false, customOptionLabel } = props;
122
+ const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions } = props;
112
123
  const labels = useLoticsLocale().inline;
113
124
  const [open, setOpen] = useState(false);
114
125
  const [saving, setSaving] = useState(false);
@@ -151,13 +162,14 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
151
162
  };
152
163
  return (
153
164
  <InlineSelectShell
165
+ variant={variant}
166
+ actions={actions}
154
167
  open={open}
155
168
  onOpenChange={onOpenChange}
156
169
  disabled={disabled}
157
170
  display={selected.length > 0 ? <View style={styles.tags}>{selected.map((o) => <View key={o.value}>{chip(o)}</View>)}</View> : ""}
158
171
  placeholder={placeholder}
159
172
  accessibilityLabel={accessibilityLabel}
160
- variant={variant}
161
173
  saving={saving}
162
174
  error={error}
163
175
  >
@@ -196,13 +208,14 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
196
208
  };
197
209
  return (
198
210
  <InlineSelectShell
211
+ variant={variant}
212
+ actions={actions}
199
213
  open={open}
200
214
  onOpenChange={setOpen}
201
215
  disabled={disabled}
202
216
  display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
203
217
  placeholder={placeholder}
204
218
  accessibilityLabel={accessibilityLabel}
205
- variant={variant}
206
219
  saving={saving}
207
220
  error={error}
208
221
  >
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, type ReactNode } from "react";
2
2
  import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
3
3
  import { TextInputField } from "./text_input_field";
4
4
  import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
@@ -15,9 +15,22 @@ export interface InlineTextInputProps {
15
15
  disabled?: boolean;
16
16
  /** Strike + mute the resting value (a completed item that stays editable). */
17
17
  struck?: boolean;
18
- /** Resting surface "tint" (default, the zinc-50 chip) or "transparent". */
18
+ /** How much frame shows at rest see {@link InlineEditVariant}. Default "framed". */
19
19
  variant?: InlineEditVariant;
20
+ /**
21
+ * Line budget for a value a reader has to read WHOLE rather than recognise —
22
+ * a payment term, an address, a clause. Default 1 (the single-line field).
23
+ * Above 1 the resting value wraps to that many lines AND the editor opens
24
+ * multiline at the same height, so the row does not reflow on click. Enter
25
+ * then inserts a newline; the field commits on blur (or the ✓ in "buttons").
26
+ */
27
+ numberOfLines?: number;
20
28
  accessibilityLabel?: string;
29
+ /** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
30
+ * quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
31
+ * field never changes width on focus, and passed UNCONDITIONALLY (`disabled` the
32
+ * verb when there is nothing to act on). */
33
+ actions?: ReactNode;
21
34
  }
22
35
 
23
36
  /**
@@ -27,16 +40,18 @@ export interface InlineTextInputProps {
27
40
  * value in a dense record / detail surface.
28
41
  */
29
42
  export function InlineTextInput(props: InlineTextInputProps) {
30
- const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant } = props;
43
+ const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
44
+ const multiline = (numberOfLines ?? 1) > 1;
31
45
  const edit = useInlineEdit<string>({ value, onSave });
32
46
 
33
47
  const onKeyPress = useCallback(
34
48
  (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
35
49
  const key = e.nativeEvent.key;
36
50
  if (key === "Escape") edit.cancel();
37
- else if (key === "Enter") void edit.commit();
51
+ // On a multiline field Enter is a NEWLINE; the value commits on blur.
52
+ else if (key === "Enter" && !multiline) void edit.commit();
38
53
  },
39
- [edit],
54
+ [edit, multiline],
40
55
  );
41
56
 
42
57
  // Commit on blur (the ergonomic default for moving through a form). In
@@ -49,7 +64,6 @@ export function InlineTextInput(props: InlineTextInputProps) {
49
64
 
50
65
  return (
51
66
  <InlineEditFrame
52
- variant={variant}
53
67
  editing={edit.editing}
54
68
  display={value}
55
69
  placeholder={placeholder}
@@ -61,7 +75,10 @@ export function InlineTextInput(props: InlineTextInputProps) {
61
75
  error={edit.error}
62
76
  disabled={disabled}
63
77
  struck={struck}
78
+ variant={variant}
79
+ numberOfLines={numberOfLines}
64
80
  accessibilityLabel={accessibilityLabel}
81
+ actions={actions}
65
82
  >
66
83
  <TextInputField
67
84
  value={edit.draft}
@@ -69,8 +86,12 @@ export function InlineTextInput(props: InlineTextInputProps) {
69
86
  onBlur={onBlur}
70
87
  onKeyPress={onKeyPress}
71
88
  autoFocus
89
+ multiline={multiline}
90
+ numberOfLines={numberOfLines}
72
91
  placeholder={placeholder}
73
92
  accessibilityLabel={accessibilityLabel}
93
+ // With verbs on the field, the FRAME owns the surface and the ring.
94
+ seamless={actions != null}
74
95
  />
75
96
  </InlineEditFrame>
76
97
  );
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef } from "react";
1
+ import { useCallback, useRef, type ReactNode } from "react";
2
2
  import { Icon } from "./icon";
3
3
  import { colors } from "./colors";
4
4
  import { TimePicker } from "./time_picker";
@@ -14,9 +14,14 @@ export interface InlineTimePickerProps {
14
14
  * an explicit ✓ saves and ✕ reverts. */
15
15
  controls?: InlineEditControls;
16
16
  disabled?: boolean;
17
- accessibilityLabel?: string;
18
- /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
17
+ /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
19
18
  variant?: InlineEditVariant;
19
+ /** Verbs on the field's surface — an `InlineButton` Copy, Open… See
20
+ * `InlineEditView.actions`: they sit INSIDE the field, in the same place in
21
+ * every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
22
+ * has nothing to act on) or the field resizes as the value fills. */
23
+ actions?: ReactNode;
24
+ accessibilityLabel?: string;
20
25
  }
21
26
 
22
27
  /**
@@ -25,7 +30,7 @@ export interface InlineTimePickerProps {
25
30
  * field at the same height, so the form never reflows.
26
31
  */
27
32
  export function InlineTimePicker(props: InlineTimePickerProps) {
28
- const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , variant } = props;
33
+ const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
29
34
  const edit = useInlineEdit<string>({ value, onSave });
30
35
  const dateLabels = useLoticsLocale().datePicker;
31
36
  // Segments emit ONLY on a complete "HH:mm", so a half-typed entry (an hour and
@@ -42,6 +47,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
42
47
  return (
43
48
  <InlineEditFrame
44
49
  variant={variant}
50
+ actions={actions}
45
51
  editing={edit.editing}
46
52
  display={value}
47
53
  placeholder={placeholder}
@@ -0,0 +1,17 @@
1
+ /** Vertical breathing room inside an input, per side. */
2
+ export const INPUT_VERTICAL_PADDING = 8;
3
+
4
+ /**
5
+ * The height a MULTI-LINE input occupies, for `numberOfLines` lines of text.
6
+ *
7
+ * One formula with two callers on purpose: `TextInputField` sizes the editor
8
+ * with it, and an inline field's RESTING box reserves the same number. When
9
+ * each computed its own, the resting box came out 22px shorter and every click
10
+ * on a payment-term field shoved the rest of the form down the page.
11
+ *
12
+ * RN-free so it can be tested directly — the caller supplies the line height,
13
+ * which is the only platform-dependent part.
14
+ */
15
+ export function multilineInputHeight(numberOfLines: number, lineHeight: number): number {
16
+ return numberOfLines * lineHeight + INPUT_VERTICAL_PADDING * 2;
17
+ }
package/src/ledger.tsx CHANGED
@@ -23,7 +23,7 @@ import { Text } from "./text";
23
23
  // <LedgerRow label="Levy" value={levy} reference={{ label: "INV-0414", onPress }} />
24
24
  // </LedgerGroup>
25
25
  // <LedgerGroup label="Received" total={-received}>
26
- // <LedgerRow label="Deposit" meta="03/06 · Cash" value={-1_550_000} tone="success" />
26
+ // <LedgerRow label="Deposit" meta="03/06, Cash" value={-1_550_000} tone="success" />
27
27
  // </LedgerGroup>
28
28
  // <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
29
29
  // </Ledger>
@@ -95,7 +95,7 @@ export function LedgerGroup(props: LedgerGroupProps) {
95
95
  export interface LedgerRowProps {
96
96
  /** The line's name — what was charged / received. */
97
97
  label: string;
98
- /** Inline context after the label — a date · method, a basis, a period. */
98
+ /** Inline context after the label — a date, method, a basis, a period. */
99
99
  meta?: string;
100
100
  /** The amount. Negative renders "− <abs>" (a receipt). */
101
101
  value: number;
package/src/locale.tsx CHANGED
@@ -65,6 +65,8 @@ export interface LoticsLocale {
65
65
  sectionHeading: { info: string };
66
66
  /** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
67
67
  chip: { remove: string };
68
+ /** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
69
+ sequence: { moveUp: string; moveDown: string; remove: string };
68
70
  /** `TrendFooter`: the direction words of the trend sentence. */
69
71
  trendFooter: { up: string; down: string };
70
72
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
@@ -114,12 +116,12 @@ export interface LoticsLocale {
114
116
  overlay: { close: string };
115
117
  /** `FileDropzone`: the resting main line, the secondary hint, and the
116
118
  * drag-hover line. `hint` is a generic default — apps override the prop with
117
- * an accepted-types line (" · PDF, ảnh"). */
119
+ * an accepted-types line ("…, PDF, ảnh"). */
118
120
  fileDropzone: { label: string; hint: string; drop: string };
119
121
  /** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
120
122
  fileThumbnail: { remove: string };
121
- /** `FilesEditor`'s shipped bar pieces (upload · select · select-all · download
122
- * · remove) and the remove confirm. A HOST verb is a plain `Button` and names
123
+ /** `FilesEditor`'s shipped bar pieces (upload, select, select-all, download,
124
+ * remove) and the remove confirm. A HOST verb is a plain `Button` and names
123
125
  * itself, so nothing here is about what an app happens to do with a file. */
124
126
  filesEditor: {
125
127
  upload: string;
@@ -222,6 +224,7 @@ export const en: LoticsLocale = {
222
224
  ledger: { rowDetails: (label) => `${label} details` },
223
225
  sectionHeading: { info: "About this data" },
224
226
  chip: { remove: "Remove" },
227
+ sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
225
228
  trendFooter: { up: "Up", down: "Down" },
226
229
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
227
230
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
@@ -373,6 +376,7 @@ export const vi: LoticsLocale = {
373
376
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
374
377
  sectionHeading: { info: "Giải thích dữ liệu" },
375
378
  chip: { remove: "Xóa" },
379
+ sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
376
380
  trendFooter: { up: "Tăng", down: "Giảm" },
377
381
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
378
382
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
package/src/matrix.tsx CHANGED
@@ -203,7 +203,7 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
203
203
 
204
204
  const isSelected = selected?.row === row.key && selected?.col === col.key;
205
205
  const pressable = !!onSelectCell && value > 0;
206
- const label = `${row.label} · ${col.label}: ${formatValue(value)}`;
206
+ const label = `${row.label}, ${col.label}: ${formatValue(value)}`;
207
207
 
208
208
  const content = showNumber ? (
209
209
  <Text size="sm" weight="medium" tabular align="center" color={value === 0 ? "zinc-500" : "default"}>
@@ -18,6 +18,17 @@ export interface NumberInputProps {
18
18
  disabled?: boolean;
19
19
  testID?: string;
20
20
  accessibilityLabel?: string;
21
+ /** The HOST paints the surface — drop this input's own border, fill, focus ring
22
+ * AND horizontal padding, so the page draws one box and the text lands on the
23
+ * host's inset rather than 8px further in. Set by an inline editor whose field
24
+ * carries `actions`: the field's shell owns the surface (so the verbs never
25
+ * move between modes) and owns the ring with it.
26
+ *
27
+ * HEIGHT and VERTICAL padding stay — those are what keep the field the same
28
+ * size in both modes; the horizontal inset is what has to be surrendered,
29
+ * because the host already spent `CONTROL_TEXT_INSET` on it and two insets
30
+ * stacked is a visible jump to the right on focus. */
31
+ seamless?: boolean;
21
32
  }
22
33
 
23
34
  /**
@@ -26,7 +37,7 @@ export interface NumberInputProps {
26
37
  * number in place on a record use `InlineNumberInput`.
27
38
  */
28
39
  export function NumberInput(props: NumberInputProps) {
29
- const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
40
+ const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, seamless } = props;
30
41
  const binding = useFormField();
31
42
  const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
32
43
  const { focusVisible, focusProps } = useFocusRing({ always: true });
@@ -55,17 +66,17 @@ export function NumberInput(props: NumberInputProps) {
55
66
  autoFocus={autoFocus}
56
67
  style={{
57
68
  height: 40,
58
- paddingLeft: 8,
59
- paddingRight: 8,
69
+ paddingLeft: seamless ? 0 : 8,
70
+ paddingRight: seamless ? 0 : 8,
60
71
  borderRadius: CONTROL_RADIUS,
61
- borderWidth: 1,
72
+ borderWidth: seamless ? 0 : 1,
62
73
  borderStyle: "solid",
63
- borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
64
- backgroundColor: colors.background,
74
+ borderColor: seamless ? "transparent" : hovered && !disabled ? HOVER_BORDER : colors.border,
75
+ backgroundColor: seamless ? "transparent" : colors.background,
65
76
  fontFamily: fontFamilyRegular,
66
77
  ...inputTextStyleWeb,
67
78
  letterSpacing: -0.4,
68
- boxShadow: focusVisible ? FOCUS_RING : "none",
79
+ boxShadow: !seamless && focusVisible ? FOCUS_RING : "none",
69
80
  outline: "none",
70
81
  boxSizing: "border-box",
71
82
  transition: "border-color 0.12s, box-shadow 0.12s",
package/src/pipeline.tsx CHANGED
@@ -45,7 +45,7 @@ export interface PipelineProps {
45
45
  * <PipelineStage status="done" title="Submitted">
46
46
  * <PipelineField label="Date"><InlineDatePicker … /></PipelineField>
47
47
  * </PipelineStage>
48
- * <PipelineStage status="current" title="In review" meta="Waiting 3d · Ops">
48
+ * <PipelineStage status="current" title="In review" meta="Waiting 3d, Ops">
49
49
  * <PipelineNote tone="warning">Sent back — missing payslips.</PipelineNote>
50
50
  * <PipelineActions>
51
51
  * <Button title="Approve" color="primary" />
package/src/popover.tsx CHANGED
@@ -656,7 +656,7 @@ export function PopoverContent(props: PopoverContentProps) {
656
656
  // the field.
657
657
  //
658
658
  // Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
659
- // `variant="cell"` select in a dense grid column — still opens a list
659
+ // a select in a dense grid column — still opens a list
660
660
  // wide enough to read, rather than inheriting a width nothing fits in.
661
661
  ...(inheritTriggerWidth &&
662
662
  !small &&
@@ -42,7 +42,7 @@ export interface PressDoorProps {
42
42
  * Wrap the content in the pressable itself (`PressableHighlight`, `FileRow`'s door) ONLY
43
43
  * when that content is non-interactive by construction.
44
44
  *
45
- * `TableRow` and `LinkedRecordBox` are the in-kit consumers.
45
+ * `TableRow` is the in-kit consumer.
46
46
  */
47
47
  export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
48
48
  const { focusVisible, focusProps } = useFocusRing();
@@ -76,7 +76,7 @@ export interface PressableHighlightProps extends PressableProps {
76
76
  * checkbox, a `Link`) must not be built on it: a button cannot contain interactive
77
77
  * descendants. Compose the role-less `PressableRow` + a `PressDoor` sibling instead —
78
78
  * the surface takes the mouse, the door takes the keyboard, the controls stay their own
79
- * tab stops (`TableRow` and `LinkedRecordBox` are the references).
79
+ * tab stops (`TableRow` is the reference).
80
80
  */
81
81
  export function PressableHighlight(props: PressableHighlightProps) {
82
82
  const {
@@ -13,7 +13,7 @@ export interface ProgressBarProps {
13
13
  * it. */
14
14
  title?: string;
15
15
  /** Caption above-right of the bar: `percentage` → "50%", `fraction` →
16
- * "1,250 / 2,500 · 50%" (separators follow the reader's locale). Reports the
16
+ * "1,250 / 2,500 (50%)" (separators follow the reader's locale). Reports the
17
17
  * TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
18
18
  format?: ProgressBarFormat;
19
19
  /**
@@ -58,7 +58,7 @@ export function ProgressBar(props: ProgressBarProps) {
58
58
  const localeTag = useLocaleTag();
59
59
  /**
60
60
  * The TRACK clamps — a fill wider than its own track is meaningless. The CAPTION does not: a
61
- * meter reading `2.100 / 2.000 · 100%` asserts a number that is false, and a reader scanning
61
+ * meter reading `2.100 / 2.000 (100%)` asserts a number that is false, and a reader scanning
62
62
  * percentages sees "exactly at the limit" when they are over it. Progress-toward-completion
63
63
  * never exceeds its max, so this only diverges for the over-allowance case, where the true
64
64
  * figure is the whole point.
@@ -87,7 +87,7 @@ export function ProgressBar(props: ProgressBarProps) {
87
87
 
88
88
  const caption =
89
89
  format === "fraction"
90
- ? `${num(value)} / ${num(max)} · ${Math.round(ratio)}%`
90
+ ? `${num(value)} / ${num(max)} (${Math.round(ratio)}%)`
91
91
  : format === "percentage"
92
92
  ? `${Math.round(ratio)}%`
93
93
  : null;
@@ -20,7 +20,7 @@ export interface RecordSummaryProps {
20
20
  * the record page's one h1; pass 2 when the band sits inside a surface
21
21
  * that already owns the h1 (a drawer with its own titled header). */
22
22
  level?: 1 | 2 | 3;
23
- /** Identity qualifiers on the title line ("HCM → Hamburg · Export · FCL") —
23
+ /** Identity qualifiers on the title line ("HCM → Hamburg, Export, FCL") —
24
24
  * sm muted; wraps under the title when narrow. */
25
25
  subtitle?: string;
26
26
  /** A status chip (a `Badge`) or any small node right after the title. */
@@ -32,7 +32,7 @@ export interface RecordSummaryProps {
32
32
 
33
33
  /**
34
34
  * The identity band of a single record's detail screen or drawer: ONE row —
35
- * title · subtitle · status chip, with an optional headline metric pinned
35
+ * title, subtitle, status chip, with an optional headline metric pinned
36
36
  * right. It exists to stop hand-rolled record headers drifting: mixed type
37
37
  * scales, several competing figures, color noise.
38
38
  *
@@ -10,8 +10,8 @@ import { Text, type HeadingLevel } from "./text";
10
10
  * ALREADY CREATED. The receipt states the outcome and routes — it never edits
11
11
  * (correction happens through the record's ordinary verbs, on the record):
12
12
  *
13
- * - ONE record → `ResultHeader` (tone mark inline on the title row · outcome
14
- * title · failure reason line) + a handful of spread `DetailRow` receipt
13
+ * - ONE record → `ResultHeader` (tone mark inline on the title row, outcome
14
+ * title, failure reason line) + a handful of spread `DetailRow` receipt
15
15
  * lines (the identifying figures, not the field wall) + a `Confidence`
16
16
  * callout naming exactly which values failed their deterministic checks.
17
17
  * - SEVERAL records → an attention-first `ListItem` register whose rows press
@@ -0,0 +1,170 @@
1
+ import { Children, createContext, isValidElement, useContext, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { IconButton } from "./icon_button";
5
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
+ import { useLoticsLocale } from "./locale";
7
+ import { Text } from "./text";
8
+
9
+ interface SequencePosition {
10
+ index: number;
11
+ count: number;
12
+ }
13
+
14
+ const SequenceItemContext = createContext<SequencePosition | null>(null);
15
+
16
+ /** Half the control band, less the dot's own radius — puts the dot on the SAME
17
+ * line as the first control in the item, not at the item's vertical centre (an
18
+ * item that wraps to two lines must not drag its marker down). */
19
+ const RAIL_LEAD = Math.round(INLINE_CONTROL_HEIGHT / 2) - 4;
20
+ const RAIL_WIDTH = 12;
21
+ /** The rail column plus its gap — what content must be indented by to line up
22
+ * under the items (an "Add" link, a caption). Exported so a caller never
23
+ * hand-picks a number that drifts from the rail. */
24
+ export const SEQUENCE_INSET = RAIL_WIDTH + 10;
25
+
26
+ export interface SequenceProps {
27
+ /** `SequenceItem`s, in order. Order IS the data — render them from your list. */
28
+ children: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * An ORDERED list whose order is part of the data — a route's stops, an
33
+ * approval chain, a set of legs — rendered as a connected rail so the sequence
34
+ * reads without a single label saying "first" or "then".
35
+ *
36
+ * Reach for it when a thing has a variable number of POSITIONS. The alternative
37
+ * — one field per position (`Origin`, `Transfer point`, `Destination`) — cannot
38
+ * hold a second middle entry and cannot say that the entries are ordered; three
39
+ * identical field rows say nothing about which comes first.
40
+ *
41
+ * It is NOT a `Timeline` (an activity FEED: who changed what, when), NOT a
42
+ * `Stepper` (fixed named positions of a wizard), and NOT a `Pipeline` (ONE
43
+ * record walking stages that each own their controls). This is a list the user
44
+ * EDITS: add, remove, reorder.
45
+ *
46
+ * Each item supplies its own content — the kit draws the rail, the position
47
+ * affordances and the spacing:
48
+ *
49
+ * <Sequence>
50
+ * {stops.map((s, i) => (
51
+ * <SequenceItem key={s.id} role={roleFor(i)} onMoveUp={…} onMoveDown={…} onRemove={…}>
52
+ * <InlineTextInput … />
53
+ * </SequenceItem>
54
+ * ))}
55
+ * </Sequence>
56
+ */
57
+ export function Sequence({ children }: SequenceProps) {
58
+ const items = Children.toArray(children).filter(isValidElement);
59
+ return (
60
+ // NO gap: the rail is continuous, so each item's trailing segment has to
61
+ // meet the next item's leading one. Item spacing is the item's own padding,
62
+ // which the rail runs through.
63
+ <View>
64
+ {items.map((child, index) => (
65
+ <SequenceItemContext.Provider key={index} value={{ index, count: items.length }}>
66
+ {child}
67
+ </SequenceItemContext.Provider>
68
+ ))}
69
+ </View>
70
+ );
71
+ }
72
+
73
+ export interface SequenceItemProps {
74
+ /** What this position IS, derived by the caller from its index (`Origin`,
75
+ * `Via`, `Destination`). Derive it, never store it — a stored role lies the
76
+ * moment the list is reordered. */
77
+ role?: string;
78
+ /** The item's editors / content. */
79
+ children: ReactNode;
80
+ /** Swap with the item above. Omit (or leave undefined on the first item) and
81
+ * the control renders disabled, so the column never changes width. */
82
+ onMoveUp?: () => void;
83
+ onMoveDown?: () => void;
84
+ onRemove?: () => void;
85
+ /** Names the item in the reorder/remove controls' announcements — "Remove
86
+ * Antwerp" rather than three identical "Remove" buttons. */
87
+ accessibilityName?: string;
88
+ /** Width of the role column. Default 74. */
89
+ roleWidth?: number;
90
+ }
91
+
92
+ /**
93
+ * One position in a {@link Sequence} — its marker, its role, its content, and
94
+ * the controls that move or drop it.
95
+ *
96
+ * The reorder affordance is a pair of BUTTONS, not a drag handle: dragging is
97
+ * invisible to the keyboard, unreachable on a screen reader, and fiddly on a
98
+ * phone, and a list of three-to-six positions does not need the expressiveness.
99
+ * They render even where they cannot act (first item, last item) so the row's
100
+ * right edge never shifts between items.
101
+ */
102
+ export function SequenceItem(props: SequenceItemProps) {
103
+ const { role, children, onMoveUp, onMoveDown, onRemove, accessibilityName, roleWidth = 74 } = props;
104
+ const pos = useContext(SequenceItemContext);
105
+ const labels = useLoticsLocale().sequence;
106
+ const first = pos == null || pos.index === 0;
107
+ const last = pos == null || pos.index === pos.count - 1;
108
+ const named = (verb: string) => (accessibilityName ? `${verb} ${accessibilityName}` : verb);
109
+ return (
110
+ <View style={styles.item}>
111
+ {/* THE RAIL — a leading segment, the dot on the control line, then a
112
+ trailing segment that fills whatever height the content takes. The end
113
+ segments are transparent rather than absent so every item keeps the
114
+ same geometry and the dots stay on one x. */}
115
+ <View style={styles.rail}>
116
+ <View style={[styles.segment, { height: RAIL_LEAD }, first && styles.segmentHidden]} />
117
+ <View style={styles.dot} />
118
+ <View style={[styles.segment, styles.segmentFill, last && styles.segmentHidden]} />
119
+ </View>
120
+ <View style={styles.body}>
121
+ {role != null ? (
122
+ <View style={{ width: roleWidth }}>
123
+ <Text size="xs" color="muted">
124
+ {role}
125
+ </Text>
126
+ </View>
127
+ ) : null}
128
+ <View style={styles.content}>{children}</View>
129
+ <View style={styles.controls}>
130
+ <IconButton
131
+ icon="chevron-up"
132
+ tooltip={labels.moveUp}
133
+ accessibilityLabel={named(labels.moveUp)}
134
+ disabled={first || onMoveUp == null}
135
+ onPress={() => onMoveUp?.()}
136
+ />
137
+ <IconButton
138
+ icon="chevron-down"
139
+ tooltip={labels.moveDown}
140
+ accessibilityLabel={named(labels.moveDown)}
141
+ disabled={last || onMoveDown == null}
142
+ onPress={() => onMoveDown?.()}
143
+ />
144
+ <IconButton
145
+ icon="x"
146
+ tooltip={labels.remove}
147
+ accessibilityLabel={named(labels.remove)}
148
+ disabled={onRemove == null}
149
+ onPress={() => onRemove?.()}
150
+ />
151
+ </View>
152
+ </View>
153
+ </View>
154
+ );
155
+ }
156
+
157
+ const styles = StyleSheet.create({
158
+ item: { flexDirection: "row", alignItems: "stretch", gap: 10 },
159
+ rail: { width: RAIL_WIDTH, alignItems: "center" },
160
+ segment: { width: 1, backgroundColor: colors.zinc[300] },
161
+ // Fills the rest of the item, INCLUDING its bottom padding, so the line
162
+ // arrives at the next item's leading segment with nothing between them.
163
+ segmentFill: { flex: 1 },
164
+ segmentHidden: { backgroundColor: "transparent" },
165
+ dot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.zinc[400] },
166
+ // The item's own spacing — a gap on the parent would break the rail.
167
+ body: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4, paddingBottom: 6 },
168
+ content: { flexGrow: 1, flexBasis: 200, minWidth: 0, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 },
169
+ controls: { flexDirection: "row", alignItems: "center", gap: 2 },
170
+ });