@lotics/ui 46.3.0 → 46.8.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.
@@ -207,6 +207,12 @@ interface InlineEditFrameProps {
207
207
  * exists to avoid is not perceptible. `InlineEditView` already renders both;
208
208
  * this only stops the frame from narrowing what it forwards. */
209
209
  display: string | ReactNode;
210
+ /** Which edge the RESTING value sits on. Money in a column takes "right", so
211
+ * a typed figure shares its edge with the derived one beside it; anything
212
+ * else keeps the default. reviewing.md probe 6: a sound money column lands
213
+ * on TWO right edges (the typed one, the computed one) — a broken one lands
214
+ * on as many as it has rows, and the reader can no longer add it up by eye. */
215
+ align?: "left" | "right";
210
216
  placeholder?: string;
211
217
  /** Enter edit mode (the view is pressed). */
212
218
  onBegin: () => void;
@@ -256,20 +262,26 @@ interface InlineEditFrameProps {
256
262
  */
257
263
  function fieldContent(o: {
258
264
  display: string | ReactNode;
265
+ /** Which edge the RESTING value sits on. Money in a column takes "right", so
266
+ * a typed figure shares its edge with the derived one beside it; anything
267
+ * else keeps the default. reviewing.md probe 6: a sound money column lands
268
+ * on TWO right edges (the typed one, the computed one) — a broken one lands
269
+ * on as many as it has rows, and the reader can no longer add it up by eye. */
270
+ align?: "left" | "right";
259
271
  placeholder?: string | ReactNode;
260
272
  struck?: boolean;
261
273
  tone?: TextColor;
262
274
  trailing?: ReactNode;
263
275
  numberOfLines?: number;
264
276
  }) {
265
- const { display, placeholder, struck, tone, trailing, numberOfLines } = o;
277
+ const { display, placeholder, struck, tone, trailing, numberOfLines, align } = o;
266
278
  const isEmptyString = typeof display === "string" && display === "";
267
279
  return (
268
280
  <>
269
281
  {isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
270
282
  <View style={styles.viewNode}>{placeholder}</View>
271
283
  ) : typeof display === "string" ? (
272
- <Text numberOfLines={numberOfLines ?? 1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
284
+ <Text numberOfLines={numberOfLines ?? 1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null, align === "right" ? styles.viewRight : null]}>
273
285
  {display || (typeof placeholder === "string" ? placeholder : "") || "—"}
274
286
  </Text>
275
287
  ) : (
@@ -293,10 +305,10 @@ function fieldContent(o: {
293
305
  * ARRIVE (loud on its own), a `framed` field already has one, so it takes the
294
306
  * tint as well.
295
307
  */
296
- function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; active?: boolean; editing?: boolean }) {
308
+ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; inert?: boolean; active?: boolean; editing?: boolean }) {
297
309
  return (hovered: boolean): StyleProp<ViewStyle>[] => [
298
310
  styles.view,
299
- (o.variant === "bare" || o.disabled) && styles.viewBare,
311
+ (o.variant === "bare" || o.disabled || o.inert) && styles.viewBare,
300
312
  o.disabled === true && styles.viewInert,
301
313
  // Keyed on the VARIANT alone, never on `disabled` — a disabled `framed`
302
314
  // field shares the line above (it must not promise a press) but stays in its
@@ -305,7 +317,7 @@ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; activ
305
317
  o.variant === "bare" && styles.viewBareBleed,
306
318
  CONTROL_TRANSITION,
307
319
  o.active && styles.viewActive,
308
- !o.active && hovered && !o.disabled && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
320
+ !o.active && hovered && !o.disabled && !o.inert && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
309
321
  // While an input is mounted inside, it brings its own height — the surface
310
322
  // must not add its resting vertical padding on top.
311
323
  o.editing && styles.editHost,
@@ -373,9 +385,30 @@ interface InlineEditViewProps {
373
385
  * rendered as-is — e.g. a `MemberChip` / `OptionBadge` for a rich resting
374
386
  * value. A node takes the flex slot; the trailing adornment still right-aligns. */
375
387
  display: string | ReactNode;
388
+ /** Which edge the RESTING value sits on. Money in a column takes "right", so
389
+ * a typed figure shares its edge with the derived one beside it; anything
390
+ * else keeps the default. reviewing.md probe 6: a sound money column lands
391
+ * on TWO right edges (the typed one, the computed one) — a broken one lands
392
+ * on as many as it has rows, and the reader can no longer add it up by eye. */
393
+ align?: "left" | "right";
376
394
  /** Empty-state content. A string reads as muted placeholder text; a NODE (a dashed
377
395
  * avatar "add" ghost, a placeholder chip) renders as-is in the value slot. */
378
396
  placeholder?: string | ReactNode;
397
+ /**
398
+ * Wear the DISABLED surface — flat, borderless, no hover, no trailing
399
+ * adornment — while staying pressable.
400
+ *
401
+ * The state `disabled` cannot express: a value nothing can CHANGE but anything
402
+ * can still OPEN. A reference on a closed record is the case — its link is
403
+ * fixed and its facts frozen, yet reading who it points at is ordinary. Marked
404
+ * `disabled` it would refuse the press that shows them; left framed it would
405
+ * be the one field on a read-only surface still advertising an edit.
406
+ *
407
+ * Like `disabled` and unlike `variant="bare"`, it does NOT take the −8px
408
+ * bleed: the field stays in the column its editable neighbours sit in, which
409
+ * is the whole reason a read-only surface reads as level.
410
+ */
411
+ inert?: boolean;
379
412
  onPress?: () => void;
380
413
  disabled?: boolean;
381
414
  accessibilityLabel?: string;
@@ -402,7 +435,15 @@ interface InlineEditViewProps {
402
435
  anchorRef?: Ref<View>;
403
436
  /** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving.
404
437
  * DECORATION only: it renders inside the press target, so it may never hold a
405
- * control (a button must not contain a button). A control goes in `actions`. */
438
+ * control (a button must not contain a button). A control goes in `actions`.
439
+ *
440
+ * **Suppressed while `disabled`.** The adornment is the field's PROMISE that
441
+ * something opens — a chevron says "a list is behind this", a calendar glyph
442
+ * says "a picker is". A disabled field keeps neither promise, and it has
443
+ * already given up the frame and the hover wash, so the glyph is then the only
444
+ * mark left saying "control" — which makes a read-only value read as an
445
+ * editable one that ignores the click. Dropping it is what leaves plain text,
446
+ * which is what a value you cannot change is. */
406
447
  trailing?: ReactNode;
407
448
  /** Verbs that live ON the field's surface — an `InlineButton` Copy, Open… Use
408
449
  * this when the act is ABOUT THE VALUE, so it travels with what it acts on
@@ -480,7 +521,7 @@ function multilineBox(numberOfLines: number | undefined): ViewStyle | null {
480
521
  }
481
522
 
482
523
  export function InlineEditView(props: InlineEditViewProps) {
483
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines, role = "button" } = props;
524
+ const { display, align, placeholder, onPress, disabled, inert, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines, role = "button" } = props;
484
525
  // A combobox MUST expose its expanded state; a button must not. Deriving it from
485
526
  // the `active` the popover callers already pass keeps the two from drifting apart
486
527
  // — there is no way to take the role without the announcement.
@@ -493,8 +534,8 @@ export function InlineEditView(props: InlineEditViewProps) {
493
534
  onPress();
494
535
  }
495
536
  : undefined;
496
- const content = fieldContent({ display, placeholder, struck, tone, trailing, numberOfLines });
497
- const surface = fieldSurface({ variant, disabled, active });
537
+ const content = fieldContent({ display, align, placeholder, struck, tone, trailing: disabled || inert ? undefined : trailing, numberOfLines });
538
+ const surface = fieldSurface({ variant, disabled, inert, active });
498
539
 
499
540
  // WITH VERBS: the shell owns the surface, the pressable is just the value
500
541
  // region inside it. `ref` stays on the PRESSABLE — it means "the focusable
@@ -557,6 +598,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
557
598
  editing,
558
599
  editOpen,
559
600
  display,
601
+ align,
560
602
  placeholder,
561
603
  onBegin,
562
604
  children,
@@ -604,6 +646,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
604
646
  <InlineEditView
605
647
  ref={viewRef}
606
648
  variant={variant}
649
+ align={align}
607
650
  display={display}
608
651
  placeholder={placeholder}
609
652
  onPress={onBegin}
@@ -662,7 +705,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
662
705
  accessibilityLabel={accessibilityLabel}
663
706
  style={styles.viewInner}
664
707
  >
665
- {fieldContent({ display, placeholder, struck, trailing: affordance, numberOfLines })}
708
+ {fieldContent({ display, align, placeholder, struck, trailing: disabled ? undefined : affordance, numberOfLines })}
666
709
  </Pressable>
667
710
  )}
668
711
  </InlineFieldShell>
@@ -814,6 +857,7 @@ const styles = StyleSheet.create({
814
857
  borderColor: HOVER_BORDER,
815
858
  },
816
859
  placeholder: { color: colors.zinc[400] },
860
+ viewRight: { textAlign: "right" as const },
817
861
  struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
818
862
  // `textAlign` is not cosmetic here: the resting box is a button-role Pressable,
819
863
  // so the UA stylesheet centres every string inside it. A STRING display never
@@ -20,6 +20,9 @@ export interface InlineNumberInputProps {
20
20
  * BOTH modes so the field never changes width on focus; pass it
21
21
  * UNCONDITIONALLY and `disabled` the verb when it has nothing to act on. */
22
22
  actions?: ReactNode;
23
+ /** Which edge the RESTING number sits on. A money column takes "right" so the
24
+ * typed figure shares its edge with the derived one beside it. */
25
+ align?: "left" | "right";
23
26
  accessibilityLabel?: string;
24
27
  }
25
28
 
@@ -29,7 +32,7 @@ export interface InlineNumberInputProps {
29
32
  * the bare numeric field at the same height, so the form never reflows.
30
33
  */
31
34
  export function InlineNumberInput(props: InlineNumberInputProps) {
32
- const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
35
+ const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel, variant, actions, align } = props;
33
36
  const edit = useInlineEdit<number | null>({ value, onSave });
34
37
 
35
38
  const onKeyDown = useCallback(
@@ -52,6 +55,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
52
55
  return (
53
56
  <InlineEditFrame
54
57
  variant={variant}
58
+ align={align}
55
59
  actions={actions}
56
60
  editing={edit.editing}
57
61
  display={display}
@@ -210,7 +210,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
210
210
  // moving to one element silently dropped it: a done task on the board kept
211
211
  // its title upright. Anything else the view used to render has to move the
212
212
  // same way, or it goes the same way — quietly.
213
- variant={variant}
213
+ variant={disabled ? "bare" : variant}
214
214
  style={
215
215
  struck
216
216
  ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] }
package/src/locale.tsx CHANGED
@@ -72,6 +72,9 @@ export interface LoticsLocale {
72
72
  inline: { saveError: string; save: string; cancel: string };
73
73
  /** `ChoiceList` custom-answer placeholder + `ClarifyWizard` navigation chrome. */
74
74
  clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
75
+ /** `ChargeLines`: the screen-reader names of a charge line's three money
76
+ * fields, each qualified by the charge it belongs to. */
77
+ chargeLines: { quantity: (label: string) => string; unitPrice: (label: string) => string; amount: (label: string) => string };
75
78
  /** `Ledger`: the screen-reader name of a peekable row. */
76
79
  ledger: { rowDetails: (label: string) => string };
77
80
  /** `RunningLedger`: the closing line's label ("Số dư hiện tại", "Tồn kho
@@ -111,6 +114,14 @@ export interface LoticsLocale {
111
114
  chip: { remove: string };
112
115
  /** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
113
116
  sequence: { moveUp: string; moveDown: string; remove: string };
117
+ /** `Board` / `BoardCard`: the card's keyboard door, its move control, the
118
+ * destination menu's items, and a column holding nothing. */
119
+ board: {
120
+ open: (title: string) => string;
121
+ move: (title: string) => string;
122
+ moveTo: (column: string) => string;
123
+ empty: string;
124
+ };
114
125
  /** `TrendFooter`: the direction words of the trend sentence. */
115
126
  trendFooter: { up: string; down: string };
116
127
  /** The countdown vocabulary — see `deadline.ts`. 0 and 1 get their own words
@@ -323,6 +334,7 @@ export const en: LoticsLocale = {
323
334
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
324
335
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
325
336
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
337
+ chargeLines: { quantity: (label) => `Quantity for ${label}`, unitPrice: (label) => `Unit price for ${label}`, amount: (label) => `Amount for ${label}` },
326
338
  ledger: { rowDetails: (label) => `${label} details` },
327
339
  runningLedger: { currentBalance: "Current balance" },
328
340
  stepper: { complete: "Complete step", progress: "Progress" },
@@ -338,6 +350,12 @@ export const en: LoticsLocale = {
338
350
  errorState: { retry: "Try again" },
339
351
  chip: { remove: "Remove" },
340
352
  sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
353
+ board: {
354
+ open: (title) => `Open ${title}`,
355
+ move: (title) => `Move ${title}`,
356
+ moveTo: (column) => `Move to ${column}`,
357
+ empty: "Nothing here",
358
+ },
341
359
  trendFooter: { up: "Up", down: "Down" },
342
360
  deadline: {
343
361
  overdue: (days) => `${days} ${days === 1 ? "day" : "days"} overdue`,
@@ -513,7 +531,7 @@ export const vi: LoticsLocale = {
513
531
  ascending: " (tăng dần)",
514
532
  descending: " (giảm dần)",
515
533
  },
516
- referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
534
+ referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Hủy" },
517
535
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…", someSelected: "một số đã chọn" },
518
536
  picker: { emptyOption: "Không có" },
519
537
  datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", chooseTime: "Chọn giờ", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
@@ -525,6 +543,7 @@ export const vi: LoticsLocale = {
525
543
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
526
544
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
527
545
  clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
546
+ chargeLines: { quantity: (label) => `Số lượng ${label}`, unitPrice: (label) => `Đơn giá ${label}`, amount: (label) => `Số tiền ${label}` },
528
547
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
529
548
  runningLedger: { currentBalance: "Số dư hiện tại" },
530
549
  stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
@@ -540,6 +559,12 @@ export const vi: LoticsLocale = {
540
559
  errorState: { retry: "Thử lại" },
541
560
  chip: { remove: "Xóa" },
542
561
  sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
562
+ board: {
563
+ open: (title) => `Mở ${title}`,
564
+ move: (title) => `Chuyển ${title}`,
565
+ moveTo: (column) => `Chuyển sang ${column}`,
566
+ empty: "Chưa có gì",
567
+ },
543
568
  trendFooter: { up: "Tăng", down: "Giảm" },
544
569
  deadline: {
545
570
  overdue: (days) => `Quá hạn ${days} ngày`,
package/src/matrix.tsx CHANGED
@@ -22,7 +22,6 @@ interface MatrixContextValue {
22
22
  heatBase: string;
23
23
  formatValue: (n: number) => string;
24
24
  rowLabelWidth: number;
25
- totalColWidth: number;
26
25
  }
27
26
 
28
27
  /** Gap between a matrix's columns. Exported because a caller sizing its own
@@ -110,7 +109,6 @@ export function Matrix(props: MatrixProps) {
110
109
  heatBase: colors[heatColor][600],
111
110
  formatValue,
112
111
  rowLabelWidth,
113
- totalColWidth: 64,
114
112
  };
115
113
 
116
114
  return (
@@ -128,7 +126,7 @@ export interface MatrixHeaderProps {
128
126
  }
129
127
 
130
128
  function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
131
- const { cols, rowLabelWidth, totalColWidth, hasTotals } = useMatrix();
129
+ const { cols, rowLabelWidth, hasTotals } = useMatrix();
132
130
  const locale = useLoticsLocale();
133
131
  const resolvedTotalLabel = totalLabel ?? locale.matrix.total;
134
132
  return (
@@ -148,7 +146,7 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
148
146
  </Text>
149
147
  ))}
150
148
  {hasTotals ? (
151
- <Text size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
149
+ <Text size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={styles.totalCol}>
152
150
  {resolvedTotalLabel}
153
151
  </Text>
154
152
  ) : null}
@@ -163,7 +161,7 @@ export interface MatrixGridProps {
163
161
 
164
162
  function MatrixGrid({ display = "both" }: MatrixGridProps) {
165
163
  const ctx = useMatrix();
166
- const { rows, cols, value, hasTotals, rowTotals, formatValue, rowLabelWidth, totalColWidth } = ctx;
164
+ const { rows, cols, value, hasTotals, rowTotals, formatValue, rowLabelWidth } = ctx;
167
165
  return (
168
166
  <View style={styles.grid}>
169
167
  {rows.map((r) => (
@@ -175,7 +173,7 @@ function MatrixGrid({ display = "both" }: MatrixGridProps) {
175
173
  <MatrixCell key={c.key} ctx={ctx} display={display} row={r} col={c} value={value(r.key, c.key)} />
176
174
  ))}
177
175
  {hasTotals ? (
178
- <Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
176
+ <Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={styles.totalCol}>
179
177
  {formatValue(rowTotals.get(r.key) ?? 0)}
180
178
  </Text>
181
179
  ) : null}
@@ -251,7 +249,7 @@ export interface MatrixTotalsProps {
251
249
  }
252
250
 
253
251
  function MatrixTotals({ label }: MatrixTotalsProps) {
254
- const { cols, colTotals, grandTotal, formatValue, rowLabelWidth, totalColWidth } = useMatrix();
252
+ const { cols, colTotals, grandTotal, formatValue, rowLabelWidth } = useMatrix();
255
253
  const locale = useLoticsLocale();
256
254
  return (
257
255
  <View style={styles.totalsRow}>
@@ -263,7 +261,7 @@ function MatrixTotals({ label }: MatrixTotalsProps) {
263
261
  {formatValue(colTotals.get(c.key) ?? 0)}
264
262
  </Text>
265
263
  ))}
266
- <Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
264
+ <Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={styles.totalCol}>
267
265
  {formatValue(grandTotal)}
268
266
  </Text>
269
267
  </View>
@@ -344,6 +342,23 @@ const styles = StyleSheet.create({
344
342
  paddingRight: 2,
345
343
  },
346
344
  totalCol: {
345
+ // SIZED LIKE A DATA COLUMN, not to a fixed number. It was `width: 64`, and a
346
+ // total is by construction the LONGEST figure in its row — so the one number
347
+ // the pivot exists to state was the one guaranteed to hit the clamp first. In
348
+ // VND it does: `formatMoney(n, {compact:true})` gives "1,51 tỷ ₫" for a grand
349
+ // total whose row cells read "231 tr ₫", and at 64px the grand total rendered
350
+ // "1,51 t…" while every cell above it fit. Nothing in the type system or a
351
+ // snapshot sees that — the string is whole in the DOM — so it ships.
352
+ //
353
+ // A per-glyph width estimate would be the wrong repair (a font decides glyph
354
+ // widths, so the arithmetic is right only at the size it was measured at).
355
+ // The structural answer is that a total is the same KIND of figure as a cell
356
+ // at the same type size, so it takes the same sizing law: one flex share of
357
+ // the leftover, floored. The floor is the old fixed width, so a matrix with
358
+ // many columns in a narrow frame never gets a total column narrower than it
359
+ // used to have.
360
+ flex: 1,
361
+ minWidth: 64,
347
362
  paddingLeft: 4,
348
363
  },
349
364
  totalsRow: {
@@ -113,15 +113,15 @@ export interface ReferenceFieldProps {
113
113
  onOpen?: () => void;
114
114
  openLabel?: string;
115
115
  /**
116
- * Point this field at a DIFFERENT record. REQUIRED.
116
+ * Point this field at a DIFFERENT record.
117
117
  *
118
118
  * Unsets the reference so the call site's picker returns, and the caller
119
119
  * should hand that picker focus: the press said "wrong one", so the next move
120
120
  * is choosing the right one and the correction should cost one gesture.
121
121
  */
122
- onChange: () => void;
122
+ onChange?: () => void;
123
123
  /**
124
- * Leave the reference EMPTY — the plain detach. REQUIRED.
124
+ * Leave the reference EMPTY — the plain detach.
125
125
  *
126
126
  * The SAME write as `onChange`; they differ only in FOLLOW-THROUGH, and
127
127
  * nothing focuses after this one — auto-opening a picker would argue with the
@@ -131,14 +131,21 @@ export interface ReferenceFieldProps {
131
131
  * either steals the caret from someone who wanted an empty field or sends
132
132
  * someone who wanted a swap hunting for the input.
133
133
  *
134
- * BOTH are required, including on a reference the record cannot do without.
135
- * Hiding Clear there enforces nothing Change, then decline to pick, lands on
136
- * the same empty state — so it only removes the direct route to a place the
137
- * reader can already reach, and makes the footer's shape vary for no gain.
138
- * Whether empty is VALID is the row's business (its `warning`, its
139
- * validation), never the peek's.
134
+ * They come as a PAIR both, or neither. Supplying one alone enforces
135
+ * nothing: Change, then decline to pick, lands on the same empty state Clear
136
+ * reaches, so hiding Clear only removes the direct route to a place the reader
137
+ * can already get to, and makes the footer's shape vary for no gain. Whether
138
+ * empty is VALID is the row's business (its `warning`, its validation), never
139
+ * the peek's.
140
+ *
141
+ * **Omitting BOTH is different, and it is the read-only reference.** On a
142
+ * record that is closed — departed, posted, archived — nothing about the link
143
+ * may change, and a Change button that renders and then declines to act is the
144
+ * same broken promise as a disabled field still wearing its chevron. Without
145
+ * them the peek keeps its facts and its Open and simply carries no link verbs,
146
+ * which is what a reference you can read but not re-point looks like.
140
147
  */
141
- onClear: () => void;
148
+ onClear?: () => void;
142
149
  /**
143
150
  * Commit the draft. Receives ONLY the facts whose value CHANGED, keyed by
144
151
  * `name` — never a full snapshot, so a lock or a `before_update` hook sees the
@@ -163,6 +170,10 @@ export function ReferenceField(props: ReferenceFieldProps) {
163
170
  const [error, setError] = useState<string | null>(null);
164
171
  const editing = draft !== null;
165
172
  const editable = onSave != null && facts.some((f) => f.name != null);
173
+ /** Chân trang chỉ có lý do tồn tại khi nó chở một động từ. Không có gì để
174
+ * đổi, để bỏ, để sửa hay để mở thì cái peek chỉ còn là các dữ kiện — và một
175
+ * đường kẻ dưới chúng chỉ nói rằng có thêm thứ gì đó, mà không có. */
176
+ const coVerb = editing || onChange != null || onClear != null || editable || onOpen != null;
166
177
 
167
178
  const openDraft = () => {
168
179
  const seed: Record<string, string> = {};
@@ -239,6 +250,12 @@ export function ReferenceField(props: ReferenceFieldProps) {
239
250
  <InlineEditView
240
251
  anchorRef={anchor}
241
252
  accessibilityLabel={accessibilityLabel}
253
+ /* Nothing here can be changed — no re-point, no detach, no fact edit —
254
+ so the field stops advertising one and rests like the read-only values
255
+ beside it. The peek still opens: reading who a closed record points at
256
+ is ordinary. Derived rather than a prop, so a caller cannot hand out
257
+ an editable surface over verbs it never passed. */
258
+ inert={onChange == null && onClear == null && onSave == null}
242
259
  onPress={() => setPeekOpen(true)}
243
260
  active={peekOpen}
244
261
  /* THE MARKER — what says this value is a RECORD and not typed text.
@@ -395,6 +412,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
395
412
  record being edited, one navigates off it — so offering either here
396
413
  would be offering to lose the typing. Cancel and Save are the only
397
414
  two exits, which is also what the pinned popover promised. */}
415
+ {coVerb ? (
398
416
  <PopoverFooter align={editing ? "end" : "space-between"}>
399
417
  {editing ? (
400
418
  <>
@@ -415,16 +433,18 @@ export function ReferenceField(props: ReferenceFieldProps) {
415
433
  </>
416
434
  ) : (
417
435
  <>
418
- {/* LEFT acts on the LINK — which record this points at. Both are
419
- unconditional: the peek always offers "point it elsewhere" and
420
- "leave it empty", so its footer has ONE shape everywhere. */}
436
+ {/* LEFT acts on the LINK — which record this points at. The two
437
+ travel together, so the footer has ONE shape wherever the link
438
+ can be re-pointed, and no left group at all where it cannot. */}
421
439
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
440
+ {onChange ? (
422
441
  <Button
423
442
  title={t.change}
424
443
  color="secondary"
425
444
  accessibilityLabel={`${t.change} — ${name}`}
426
445
  onPress={() => { setPeekOpen(false); onChange(); }}
427
446
  />
447
+ ) : null}
428
448
  {/* Same resting surface as `change` beside it. It had NO
429
449
  color — which renders transparent, borderless and
430
450
  undecorated, i.e. the hover-only affordance composition.md
@@ -433,12 +453,14 @@ export function ReferenceField(props: ReferenceFieldProps) {
433
453
  before they read any label. Being the least-reached verb is
434
454
  said by PLACEMENT — second in the left group, far from the
435
455
  primary — not by drawing nothing. */}
456
+ {onClear ? (
436
457
  <Button
437
458
  title={t.clear}
438
459
  color="secondary"
439
460
  accessibilityLabel={`${t.clear} — ${name}`}
440
461
  onPress={() => { setPeekOpen(false); onClear(); }}
441
462
  />
463
+ ) : null}
442
464
  </View>
443
465
  {/* RIGHT acts on the RECORD the link points at. Edit takes the ONE
444
466
  filled-dark rung because it is the only verb here that leads to
@@ -459,6 +481,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
459
481
  </>
460
482
  )}
461
483
  </PopoverFooter>
484
+ ) : null}
462
485
  </PopoverContent>
463
486
  </Popover>
464
487
  );
package/src/table.tsx CHANGED
@@ -331,7 +331,12 @@ export function TableGroup(props: TableGroupProps) {
331
331
  return (
332
332
  <View>
333
333
  <View style={styles.groupHeading}>
334
- {color ? <View style={[styles.groupDot, { backgroundColor: solid(color) }]} /> : null}
334
+ {/* Ô này giữ chỗ kể cả khi băng không có màu: bỏ hẳn nó đi thì nhãn của
335
+ băng trung tính bắt đầu sớm hơn nhãn băng có màu đúng một chấm cộng
336
+ khoảng cách, và hai băng nằm ngay cạnh nhau trong cùng một bảng. */}
337
+ <View
338
+ style={[styles.groupDot, color ? { backgroundColor: solid(color) } : { opacity: 0 }]}
339
+ />
335
340
  <Text size="sm" weight="semibold">
336
341
  {label}
337
342
  </Text>
package/src/tabs.tsx CHANGED
@@ -77,7 +77,7 @@ export function Tabs<T extends string>(props: TabsProps<T>) {
77
77
 
78
78
  return (
79
79
  <View
80
- style={{ flexDirection: "row", gap: 4 }}
80
+ style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 4, rowGap: 4 }}
81
81
  accessibilityRole="tablist"
82
82
  accessibilityLabel={accessibilityLabel}
83
83
  >