@lotics/ui 22.2.0 → 23.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 (68) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +27 -25
  4. package/docs/catalog.md +119 -98
  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 +3 -2
  26. package/src/agent_run.tsx +40 -25
  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_rows.tsx +1 -1
  38. package/src/finding.tsx +4 -4
  39. package/src/form_field.tsx +1 -1
  40. package/src/format_date.ts +2 -2
  41. package/src/heatmap.tsx +1 -1
  42. package/src/inline_button.tsx +84 -0
  43. package/src/inline_date_picker.tsx +17 -10
  44. package/src/inline_edit.tsx +298 -59
  45. package/src/inline_member_select.tsx +8 -3
  46. package/src/inline_number_input.tsx +11 -4
  47. package/src/inline_select.tsx +26 -13
  48. package/src/inline_text_input.tsx +12 -4
  49. package/src/inline_time_picker.tsx +10 -4
  50. package/src/ledger.tsx +2 -2
  51. package/src/locale.tsx +7 -3
  52. package/src/matrix.tsx +1 -1
  53. package/src/number_input.tsx +18 -7
  54. package/src/pipeline.tsx +1 -1
  55. package/src/popover.tsx +1 -1
  56. package/src/press_door.tsx +1 -1
  57. package/src/pressable_highlight.tsx +1 -1
  58. package/src/progress_bar.tsx +3 -3
  59. package/src/record_summary.tsx +2 -2
  60. package/src/result_header.tsx +2 -2
  61. package/src/sequence.tsx +170 -0
  62. package/src/share_or_download.ts +2 -2
  63. package/src/step_progress.tsx +7 -5
  64. package/src/stepper.tsx +1 -1
  65. package/src/task.tsx +6 -6
  66. package/src/text_input_field.tsx +19 -1
  67. package/src/text_utils.ts +1 -1
  68. package/src/linked_record_box.tsx +0 -157
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, type ReactNode, type Ref } from "react";
2
- import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
2
+ import { Pressable, View, StyleSheet, type GestureResponderEvent, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
5
5
  import { useLoticsLocale } from "./locale";
@@ -118,15 +118,25 @@ export function useInlineEditFocusRestore(editing: boolean, restore: () => void)
118
118
  }, [editing, restore]);
119
119
  }
120
120
 
121
- /** Which surface an inline editor lives on — the ONE axis that separates a FORM
122
- * field from a data-grid CELL:
123
- * - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
124
- * affordance on surfaces that MIX editable and static values (a `DetailTable`).
125
- * - `"cell"`: transparent at rest + a background-tint WASH on hover (the same
126
- * `PressableHighlight` language as the row/cell it sits in). For DENSE,
127
- * uniformly-editable surfaces where every cell edits a register/`DataGrid`
128
- * column, a task rowwhere the repeated chip + per-field border are noise. */
129
- export type InlineEditVariant = "form" | "cell";
121
+ /**
122
+ * How much of the field's frame shows AT REST — an axis of WEIGHT, not of use.
123
+ *
124
+ * - `"framed"` (default): the pill surface always visible white, 1px border.
125
+ * Required wherever editable and static values MIX (a record's `DetailTable`),
126
+ * because there the frame is the only thing saying which values you can change.
127
+ * - `"bare"`: nothing at rest; the same border fades in on hover. For a surface
128
+ * where EVERY value is editable a register/`DataGrid` column, a task row
129
+ * the per-field frame states what the whole surface already promises, and
130
+ * repeated down a column it draws the grid a second time.
131
+ *
132
+ * Open is identical either way (the 2px ring). HOVER is not, and deliberately:
133
+ * hover must be a CHANGE you can see, so a `bare` field has its border arrive out
134
+ * of nothing, while a `framed` one — already bordered — deepens that border AND
135
+ * tints, since zinc-200 → zinc-400 on a 1px line is too quiet on its own.
136
+ *
137
+ * Either way this changes what a field looks like, never what it does.
138
+ */
139
+ export type InlineEditVariant = "framed" | "bare";
130
140
 
131
141
  interface InlineEditFrameProps {
132
142
  editing: boolean;
@@ -148,12 +158,134 @@ interface InlineEditFrameProps {
148
158
  accessibilityLabel?: string;
149
159
  /** Strike + mute the resting value (a completed item that stays editable). */
150
160
  struck?: boolean;
151
- /** Form field or grid cell — see {@link InlineEditVariant}. Default "form". */
161
+ /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
152
162
  variant?: InlineEditVariant;
153
163
  /** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
154
164
  * field) — a hover-independent cue the field opens a picker. Omit for plain
155
165
  * text/number inputs (typing IS the affordance). */
156
166
  affordance?: ReactNode;
167
+ /** Verbs on the field's surface — see `InlineEditView.actions`. Rendered in BOTH
168
+ * modes (inside the chip at rest, beside the input while editing) so the field
169
+ * never changes width on focus or on commit. Pass it UNCONDITIONALLY and
170
+ * `disabled` the verb when it has nothing to act on: a slot that appears once
171
+ * the value is non-empty is the same layout shift by another route. */
172
+ actions?: ReactNode;
173
+ }
174
+
175
+ /**
176
+ * The field's VALUE REGION — what a reader sees where the value is, in the one
177
+ * form both modes agree on.
178
+ *
179
+ * Empty string display → the placeholder (a node renders as-is, a string reads
180
+ * muted); a node display renders as-is; a value string wears the value style
181
+ * (+ tone). Shared so a field that swaps to an input and back cannot change how
182
+ * its resting value is drawn on the way through.
183
+ */
184
+ function fieldContent(o: {
185
+ display: string | ReactNode;
186
+ placeholder?: string | ReactNode;
187
+ struck?: boolean;
188
+ tone?: TextColor;
189
+ trailing?: ReactNode;
190
+ }) {
191
+ const { display, placeholder, struck, tone, trailing } = o;
192
+ const isEmptyString = typeof display === "string" && display === "";
193
+ return (
194
+ <>
195
+ {isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
196
+ <View style={styles.viewNode}>{placeholder}</View>
197
+ ) : typeof display === "string" ? (
198
+ <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
199
+ {display || (typeof placeholder === "string" ? placeholder : "") || "—"}
200
+ </Text>
201
+ ) : (
202
+ <View style={styles.viewNode}>{display}</View>
203
+ )}
204
+ {trailing != null ? trailing : null}
205
+ </>
206
+ );
207
+ }
208
+
209
+ /**
210
+ * THE field's resting/hover/open styling, in ONE place.
211
+ *
212
+ * It is a function rather than four style arrays because both modes need the
213
+ * SAME answer: a field that hovers one way at rest and another way while editing
214
+ * flashes as the two disagree — which is exactly what a hand-assembled second
215
+ * copy did (it painted the border but dropped the tint, so clicking into a
216
+ * framed field made its hover fill vanish for the length of the swap).
217
+ *
218
+ * `variant` decides what hover has left to SAY: a `bare` field has its border
219
+ * ARRIVE (loud on its own), a `framed` field already has one, so it takes the
220
+ * tint as well.
221
+ */
222
+ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; active?: boolean; editing?: boolean }) {
223
+ return (hovered: boolean): StyleProp<ViewStyle>[] => [
224
+ styles.view,
225
+ (o.variant === "bare" || o.disabled) && styles.viewBare,
226
+ CONTROL_TRANSITION,
227
+ o.active && styles.viewActive,
228
+ !o.active && hovered && !o.disabled && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
229
+ // While an input is mounted inside, it brings its own height — the surface
230
+ // must not add its resting vertical padding on top.
231
+ o.editing && styles.editHost,
232
+ ];
233
+ }
234
+
235
+ /**
236
+ * THE FIELD SURFACE — the one implementation of "a bordered box holding a
237
+ * control and, optionally, the verbs that act on its value".
238
+ *
239
+ * It exists because that box used to be assembled by hand in four places (the
240
+ * resting view with and without verbs, the editing frame with and without), and
241
+ * the copies drifted: one drew a border inside a border, one added its resting
242
+ * padding to an input that already had a height, one killed the focus ring.
243
+ * Every one of those was a call site re-deriving the same anatomy.
244
+ *
245
+ * The anatomy it owns, once:
246
+ * - the surface + its `variant` / hover / open / disabled states;
247
+ * - HOVER TRACKED HERE, on the box, not on the control inside it —
248
+ * react-native-web hands a parent's hover to the innermost pressable, so a
249
+ * control-tracked border drops out the moment the pointer crosses a verb;
250
+ * - FOCUS-WITHIN, so the ring paints on the whole field rather than on the text
251
+ * region inside it: React bubbles focus, so the box hears the control it
252
+ * contains, and the box is what a reader perceives as "the field";
253
+ * - the verbs as SIBLINGS of the control, never nested in it (a button must not
254
+ * contain a button), on the surface's own right edge.
255
+ *
256
+ * The CONTROL it wraps must draw no surface of its own — that is what
257
+ * `seamless` means on `TextInputField` / `NumberInput`. One box, one owner.
258
+ */
259
+ function InlineFieldShell(props: {
260
+ surface: (hovered: boolean) => StyleProp<ViewStyle>[];
261
+ actions: ReactNode;
262
+ /** The outer box, for a caller that anchors an overlay to the FIELD (a
263
+ * select's popover inherits this width — the control inside is narrower). */
264
+ anchorRef?: Ref<View>;
265
+ /** Set while the control inside holds focus; the ring paints on the box. */
266
+ focusable?: boolean;
267
+ children: ReactNode;
268
+ }) {
269
+ const { surface, actions, anchorRef, focusable = true, children } = props;
270
+ const [hovered, setHovered] = useState(false);
271
+ const [focused, setFocused] = useState(false);
272
+ // RN's types declare neither; react-native-web forwards both to the DOM node
273
+ // (the same boundary cast `PressableRow` uses for its own hover).
274
+ const domProps = {
275
+ onMouseEnter: () => setHovered(true),
276
+ onMouseLeave: () => setHovered(false),
277
+ ...(focusable ? { onFocus: () => setFocused(true), onBlur: () => setFocused(false) } : {}),
278
+ } as object;
279
+ return (
280
+ <View
281
+ ref={anchorRef}
282
+ style={[...surface(hovered), styles.shell, focused && styles.shellFocused]}
283
+ {...domProps}
284
+ >
285
+ <View style={styles.shellControl}>{children}</View>
286
+ {actions}
287
+ </View>
288
+ );
157
289
  }
158
290
 
159
291
  interface InlineEditViewProps {
@@ -167,8 +299,34 @@ interface InlineEditViewProps {
167
299
  onPress?: () => void;
168
300
  disabled?: boolean;
169
301
  accessibilityLabel?: string;
170
- /** Right-aligned adornment e.g. a chevron for a select, a spinner while saving. */
302
+ /** How much frame shows at rest see {@link InlineEditVariant}. Default "framed". */
303
+ variant?: InlineEditVariant;
304
+ /**
305
+ * A handle on the FIELD's outer box, for a caller that anchors an overlay to
306
+ * it — a select's popover inherits the trigger's width, and with `actions` the
307
+ * control inside the field is narrower than the field, so anchoring to `ref`
308
+ * would open a dropdown too small for the row it belongs to.
309
+ *
310
+ * Distinct from `ref` on purpose: `ref` means "the focusable control" (the
311
+ * thing `.focus()` restores focus to), this means "the box you see". They are
312
+ * the same element only when the field carries no verbs.
313
+ */
314
+ anchorRef?: Ref<View>;
315
+ /** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving.
316
+ * DECORATION only: it renders inside the press target, so it may never hold a
317
+ * control (a button must not contain a button). A control goes in `actions`. */
171
318
  trailing?: ReactNode;
319
+ /** Verbs that live ON the field's surface — an `InlineButton` Copy, Open… Use
320
+ * this when the act is ABOUT THE VALUE, so it travels with what it acts on
321
+ * instead of pairing across the row to a trailing column.
322
+ *
323
+ * Passing it changes the ANATOMY, because it has to: the surface moves to a
324
+ * wrapper and the press target becomes the value region beside these verbs, so
325
+ * each keeps its own press and neither nests in the other. The focus ring then
326
+ * rings the value region — the element that actually took focus — rather than
327
+ * the whole field. Everything else (hover on the full surface, the ref, the
328
+ * active/open edge) is unchanged. */
329
+ actions?: ReactNode;
172
330
  /** True while the field's popover (select/date) is open: wears the 2px active
173
331
  * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
174
332
  active?: boolean;
@@ -179,8 +337,6 @@ interface InlineEditViewProps {
179
337
  onFocus?: () => void;
180
338
  /** Strike + mute the resting value (a completed/superseded item that stays editable). */
181
339
  struck?: boolean;
182
- /** Form field or grid cell — see {@link InlineEditVariant}. Default "form". */
183
- variant?: InlineEditVariant;
184
340
  /** Colours the resting VALUE by semantic state — an overdue date red, a soon-due
185
341
  * amber (the caller owns the rule). Applies only to a set, non-struck string value;
186
342
  * the placeholder + struck styles win. Default: the standard value ink. */
@@ -189,18 +345,18 @@ interface InlineEditViewProps {
189
345
  }
190
346
 
191
347
  /**
192
- * The view-mode box of an inline-editable field: the value as plain text in a
193
- * `FocusRingPressable` (an input, so it hovers via its BORDER the transparent
194
- * edge reveals on hover, never a grey wash; the cursor stays the default arrow —
195
- * the zinc-50 chip + hover-border are the affordance, a pointer would read as a
196
- * button), at the kit's control height with a transparent border so
197
- * swapping to an input, or floating a dropdown above it, never shifts the
198
- * layout. Used by `InlineEditFrame`, and as the overlay trigger for the
199
- * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
348
+ * The view-mode box of an inline-editable field: the value as plain text on the
349
+ * shared control surface white with a 1px border when `framed`, frameless
350
+ * until hover when `bare`. It hovers via its BORDER, never a grey wash, and the
351
+ * cursor stays the default arrow: the surface itself is the affordance, and a
352
+ * pointer would read as a button. The border is present at every state (merely
353
+ * transparent when `bare` rests), so swapping to an input, or floating a
354
+ * dropdown above it, never shifts the layout. Used by `InlineEditFrame`, and as
355
+ * the overlay trigger for the select/date inline editors (it forwards ref +
356
+ * onPress to a `PopoverTrigger`).
200
357
  */
201
358
  export function InlineEditView(props: InlineEditViewProps) {
202
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, variant = "form", tone, onFocus, ref } = props;
203
- const isEmptyString = typeof display === "string" && display === "";
359
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef } = props;
204
360
  // Stop the press here so an inline editor nested in a pressable row (a task
205
361
  // row that expands on press) edits the field instead of triggering the row.
206
362
  const handlePress = onPress
@@ -209,33 +365,51 @@ export function InlineEditView(props: InlineEditViewProps) {
209
365
  onPress();
210
366
  }
211
367
  : undefined;
368
+ const content = fieldContent({ display, placeholder, struck, tone, trailing });
369
+ const surface = fieldSurface({ variant, disabled, active });
370
+
371
+ // WITH VERBS: the shell owns the surface, the pressable is just the value
372
+ // region inside it. `ref` stays on the PRESSABLE — it means "the focusable
373
+ // control", which `InlineEditFrame` calls `.focus()` on to restore focus after
374
+ // a keyboard close; `anchorRef` is the separate handle for the outer box.
375
+ if (actions != null) {
376
+ return (
377
+ <InlineFieldShell surface={surface} actions={actions} anchorRef={anchorRef}>
378
+ {/* A PLAIN Pressable, not `FocusRingPressable`: the SHELL paints the ring
379
+ (focus-within), and a control that rang as well would draw a second
380
+ 2px box tight around the value — which reads as the value jumping the
381
+ moment the field takes focus. The ring has exactly one owner, for the
382
+ same reason the surface and the text inset each do. */}
383
+ <Pressable
384
+ ref={ref}
385
+ disabled={disabled}
386
+ onPress={handlePress}
387
+ onFocus={onFocus}
388
+ accessibilityRole="button"
389
+ accessibilityLabel={accessibilityLabel}
390
+ style={styles.viewInner}
391
+ >
392
+ {content}
393
+ </Pressable>
394
+ </InlineFieldShell>
395
+ );
396
+ }
397
+
212
398
  return (
213
399
  <FocusRingPressable
214
- ref={ref}
400
+ // No verbs: the box and the control are the same element, so one ref
401
+ // serves both jobs. `anchorRef` wins when both are passed — a caller that
402
+ // asked for the box gets the box.
403
+ ref={anchorRef ?? ref}
215
404
  disabled={disabled}
216
405
  onPress={handlePress}
217
406
  onFocus={onFocus}
218
407
  accessibilityRole="button"
219
408
  accessibilityLabel={accessibilityLabel}
220
409
  userSelect="none"
221
- // Hover language follows the variant: a `form` field reveals its BORDER
222
- // (an input's edge); a `cell` field lives in a dense grid/row/task context,
223
- // so it takes the background-tint WASH — the same `PressableHighlight`
224
- // language as the cells beside it. Both suppressed while open (fill + ring lead).
225
- style={(state) => [styles.view, (variant === "cell" || disabled) && styles.viewTransparent, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && (variant === "cell" ? styles.viewHoveredWash : styles.viewHovered)]}
410
+ style={(state) => surface(state.hovered ?? false)}
226
411
  >
227
- {/* Empty string display → the placeholder (a node renders as-is, a string reads muted);
228
- a node display renders as-is; a value string wears the value style (+ tone). */}
229
- {isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
230
- <View style={styles.viewNode}>{placeholder}</View>
231
- ) : typeof display === "string" ? (
232
- <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
233
- {display || (typeof placeholder === "string" ? placeholder : "") || "—"}
234
- </Text>
235
- ) : (
236
- <View style={styles.viewNode}>{display}</View>
237
- )}
238
- {trailing != null ? trailing : null}
412
+ {content}
239
413
  </FocusRingPressable>
240
414
  );
241
415
  }
@@ -267,6 +441,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
267
441
  struck,
268
442
  variant,
269
443
  affordance,
444
+ actions,
270
445
  } = props;
271
446
 
272
447
  const viewRef = useRef<View>(null);
@@ -292,7 +467,9 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
292
467
  }, []),
293
468
  );
294
469
 
295
- if (!editing) {
470
+ // WITHOUT verbs the two modes are genuinely different elements (the resting
471
+ // button, then the input), and each owns its own surface — unchanged.
472
+ if (actions == null && !editing) {
296
473
  return (
297
474
  <InlineEditView
298
475
  ref={viewRef}
@@ -312,6 +489,46 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
312
489
  return (
313
490
  <View>
314
491
  <View style={styles.editRow}>
492
+ {/* ONE SHELL, ACROSS BOTH MODES — not "the same shape rebuilt", the same
493
+ ELEMENT. Only its contents swap.
494
+
495
+ Rebuilding it per mode looked equivalent and was not: the new shell
496
+ mounted under a stationary pointer, which fires no `mouseenter`, so it
497
+ came up UN-hovered and the hover styling dropped for the length of the
498
+ swap — the flash. Anything the shell tracks would have been lost the
499
+ same way. Persisting it also means the verbs are not remounted, so
500
+ they cannot move, which is the contract this whole path exists for. */}
501
+ {actions != null ? (
502
+ <View style={styles.editControl}>
503
+ <InlineFieldShell
504
+ surface={fieldSurface({ variant: variant ?? "framed", disabled, editing })}
505
+ actions={actions}
506
+ >
507
+ {editing ? (
508
+ <>
509
+ {children}
510
+ {saving ? (
511
+ <View style={styles.savingOverlay} pointerEvents="none">
512
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
513
+ </View>
514
+ ) : null}
515
+ </>
516
+ ) : (
517
+ <Pressable
518
+ ref={viewRef}
519
+ disabled={disabled}
520
+ onPress={onBegin}
521
+ onFocus={handleViewFocus}
522
+ accessibilityRole="button"
523
+ accessibilityLabel={accessibilityLabel}
524
+ style={styles.viewInner}
525
+ >
526
+ {fieldContent({ display, placeholder, struck, trailing: affordance })}
527
+ </Pressable>
528
+ )}
529
+ </InlineFieldShell>
530
+ </View>
531
+ ) : (
315
532
  <View style={styles.editControl}>
316
533
  {children}
317
534
  {/* The saving spinner floats inside the input's right edge — it must
@@ -322,6 +539,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
322
539
  </View>
323
540
  ) : null}
324
541
  </View>
542
+ )}
325
543
  {controls === "buttons" ? (
326
544
  <View style={styles.buttons}>
327
545
  <IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
@@ -353,16 +571,17 @@ export const inlineValueTextStyle: TextStyle = {
353
571
  const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
354
572
 
355
573
  const styles = StyleSheet.create({
356
- // At REST an editor sits on a zinc-50 chipTHE editability affordance: a
357
- // read-only `InlineStatic` stays flat, so editable and static values are
358
- // distinguishable without hovering (they were pixel-identical before, and
359
- // users could not discover what was editable).
574
+ // At REST an editor wears THE pill surface white, 1px border the same one
575
+ // `Chip`, `ChipGroup` and a secondary `Button` wear, so every control a record
576
+ // row can hold reads as one family. It is still THE editability affordance: a
577
+ // read-only `InlineStatic` stays flat and borderless, and a bordered box beside
578
+ // bare text is a plainer signal than a tint ever was.
360
579
  view: {
361
580
  minHeight: INLINE_CONTROL_HEIGHT,
362
581
  borderRadius: CONTROL_RADIUS,
363
582
  borderWidth: 1,
364
- borderColor: "transparent",
365
- backgroundColor: colors.zinc[50],
583
+ borderColor: colors.border,
584
+ backgroundColor: colors.white,
366
585
  paddingHorizontal: 8,
367
586
  // Vertical padding matters only when the display content is TALLER than
368
587
  // one line (a rich select showing title + description at rest): minHeight
@@ -377,30 +596,50 @@ const styles = StyleSheet.create({
377
596
  // as a button press). Overrides the Pressable's pointer default.
378
597
  cursor: "auto",
379
598
  },
380
- // The transparent opt-outdense, uniformly-editable surfaces.
381
- viewTransparent: { backgroundColor: "transparent" },
382
- // Open (popover showing): the transparent edge fills in to the kit's 1px border
383
- // under the 2px active ring identical to a focused input; the chip clears to
384
- // the input's white surface.
599
+ // Nothing at restthe frame arrives on hover. Also how a DISABLED field
600
+ // rests, whatever its variant: an inert value must not promise a press.
601
+ viewBare: { backgroundColor: "transparent", borderColor: "transparent" },
602
+ // The shell's own inset: the right edge tightens to 4 so an `InlineButton` sits
603
+ // on the surface's edge with the same air a `Chip`'s dismiss gets.
604
+ shell: { paddingRight: 4 },
605
+ // Focus paints on the FIELD, not on the value region inside it.
606
+ shellFocused: { borderColor: colors.border, boxShadow: FOCUS_RING },
607
+ // The control's slot: it takes the slack so the verbs keep the right edge.
608
+ shellControl: { flex: 1, minWidth: 0, alignSelf: "stretch", justifyContent: "center", position: "relative" },
609
+ // The value region inside a verb-hosting resting field: carries the row layout
610
+ // the surface would otherwise own. `userSelect` here rather than via the
611
+ // `FocusRingPressable` prop — a drag across a value must edit, not select text.
612
+ viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none", cursor: "auto" } as ViewStyle) },
613
+ // Open (popover showing): the resting surface plus the 2px active ring — the
614
+ // one thing that separates open from at-rest, and identical to a focused input.
385
615
  viewActive: {
386
616
  borderColor: colors.border,
387
617
  backgroundColor: colors.white,
388
618
  boxShadow: FOCUS_RING,
389
619
  },
390
- // Hover (not open): a tint FORM field reveals the kit hover-border (input family);
391
- // a transparent CELL/dense field takes the background-tint wash (zinc-100), the
392
- // same language as the cells beside it.
620
+ // Hover on a BARE field: the border arrives out of nothing the whole signal.
393
621
  viewHovered: {
394
622
  borderColor: HOVER_BORDER,
395
623
  },
396
- viewHoveredWash: {
397
- backgroundColor: colors.zinc[100],
624
+ // Hover on a FRAMED field: the border deepens AND the surface tints. The border
625
+ // alone would be zinc-200 → zinc-400, a shade shift on a 1px line — too quiet to
626
+ // mark the field you are about to act on. The tint is the old resting chip,
627
+ // promoted to the state it always read as: "this one".
628
+ //
629
+ // It is deliberately NOT applied to `bare`: those live inside rows that wash on
630
+ // hover themselves, and a cell lighter than the row under it reads backwards.
631
+ viewHoveredFramed: {
632
+ borderColor: HOVER_BORDER,
633
+ backgroundColor: colors.zinc[50],
398
634
  },
399
635
  placeholder: { color: colors.zinc[400] },
400
636
  struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
401
637
  viewNode: { flex: 1, minWidth: 0 },
402
638
  editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
403
639
  editControl: { flex: 1, position: "relative" },
640
+ // The input brings its own 40px height, so the surface must not add its resting
641
+ // vertical padding on top — that was a 12px jump the moment the field focused.
642
+ editHost: { paddingVertical: 0 },
404
643
  savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
405
644
  buttons: { flexDirection: "row", gap: 6, height: INLINE_CONTROL_HEIGHT, alignItems: "center" },
406
645
  error: { marginTop: 4 },
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useCallback, useMemo, type ReactNode } from "react";
2
2
  import { View, StyleSheet } from "react-native";
3
3
  import type { InlineEditVariant } from "./inline_edit";
4
4
  import { InlineSelect } from "./inline_select";
@@ -26,9 +26,14 @@ interface InlineMemberSelectProps {
26
26
  onClear?: () => void | Promise<void>;
27
27
  placeholder?: string;
28
28
  disabled?: boolean;
29
- accessibilityLabel?: string;
30
- /** Form field (default) or grid cell — see {@link InlineEditVariant}. */
29
+ /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
31
30
  variant?: InlineEditVariant;
31
+ /** Verbs on the field's surface — an `InlineButton` Copy, Open… See
32
+ * `InlineEditView.actions`: they sit INSIDE the field, in the same place in
33
+ * every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
34
+ * has nothing to act on) or the field resizes as the value fills. */
35
+ actions?: ReactNode;
36
+ accessibilityLabel?: string;
32
37
  /** Show an in-menu search box to filter the roster; default false. */
33
38
  searchable?: boolean;
34
39
  /** Resting display = the bare AVATAR only (no name), a dashed "add" ghost when
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, type ReactNode } from "react";
2
2
  import type { KeyboardEvent } from "react";
3
3
  import { NumberInput } from "./number_input";
4
4
  import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
@@ -14,9 +14,13 @@ export interface InlineNumberInputProps {
14
14
  max?: number;
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 — see `InlineEditView.actions`. Rendered in
20
+ * BOTH modes so the field never changes width on focus; pass it
21
+ * UNCONDITIONALLY and `disabled` the verb when it has nothing to act on. */
22
+ actions?: ReactNode;
23
+ accessibilityLabel?: string;
20
24
  }
21
25
 
22
26
  /**
@@ -25,7 +29,7 @@ export interface InlineNumberInputProps {
25
29
  * the bare numeric field at the same height, so the form never reflows.
26
30
  */
27
31
  export function InlineNumberInput(props: InlineNumberInputProps) {
28
- const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel , variant } = props;
32
+ const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
29
33
  const edit = useInlineEdit<number | null>({ value, onSave });
30
34
 
31
35
  const onKeyDown = useCallback(
@@ -46,6 +50,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
46
50
  return (
47
51
  <InlineEditFrame
48
52
  variant={variant}
53
+ actions={actions}
49
54
  editing={edit.editing}
50
55
  display={display}
51
56
  placeholder={placeholder}
@@ -67,6 +72,8 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
67
72
  min={min}
68
73
  max={max}
69
74
  accessibilityLabel={accessibilityLabel}
75
+ // With verbs on the field, the FRAME owns the surface and the ring.
76
+ seamless={actions != null}
70
77
  />
71
78
  </InlineEditFrame>
72
79
  );
@@ -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
  >