@lotics/ui 44.7.1 → 44.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.
@@ -184,19 +184,18 @@ export function InlineTextInput(props: InlineTextInputProps) {
184
184
  // Everything the resting VIEW used to draw, the input now draws, because
185
185
  // there is no resting view left to draw it. Both of these were its job:
186
186
  //
187
- // `bare` — the transparent resting edge. Hover and the focus ring are
188
- // applied after `style` inside `TextInputField`, so it still darkens on
189
- // hover and still rings on focus, from the element that now owns it.
187
+ // `variant` — the resting frame, or none. It goes over as the PROP, not
188
+ // as a `style` override: the two are not equivalent, because `style` is
189
+ // applied after the hover rule and so erased the hover edge that is a
190
+ // bare field's only affordance. The field owns its own variants.
190
191
  //
191
192
  // `struck` — a completed item's value, line-through and muted. This was
192
193
  // applied in `fieldContent`, which only ever runs in the resting view, so
193
194
  // moving to one element silently dropped it: a done task on the board kept
194
195
  // its title upright. Anything else the view used to render has to move the
195
196
  // same way, or it goes the same way — quietly.
196
- style={[
197
- variant === "bare" ? { borderColor: "transparent", backgroundColor: "transparent" } : null,
198
- struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null,
199
- ]}
197
+ variant={variant}
198
+ style={struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null}
200
199
  // With verbs on the field, the FRAME owns the surface and the ring.
201
200
  seamless={actions != null}
202
201
  />
package/src/locale.tsx CHANGED
@@ -74,11 +74,20 @@ export interface LoticsLocale {
74
74
  clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
75
75
  /** `Ledger`: the screen-reader name of a peekable row. */
76
76
  ledger: { rowDetails: (label: string) => string };
77
- /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
78
77
  /** `Step`/`ChecklistItem`'s toggleable MARKER, when the caller names no
79
78
  * label of its own. A pipeline stage always passes its title, so this is the
80
79
  * bare-`Step` fallback. */
81
80
  stepper: { complete: string; progress: string };
81
+ /** `StepProgress`: the built-in caption over NAMED stages, and the
82
+ * screen-reader position when there is no caption to borrow. The counts are
83
+ * interpolated by the pack rather than concatenated at the call site, because
84
+ * where the number sits in the sentence is a property of the language. */
85
+ stepProgress: {
86
+ complete: (total: number) => string;
87
+ stage: (name: string, index: number, total: number) => string;
88
+ none: (total: number) => string;
89
+ position: (index: number, total: number) => string;
90
+ };
82
91
  /** `ChecklistGroup`'s disclosure — the small pressable text that shows or hides
83
92
  * a phase's rows. The kit owns the wording so one app does not say "Show" while
84
93
  * the next says "Expand" for the same gesture. */
@@ -89,6 +98,8 @@ export interface LoticsLocale {
89
98
  * because word order is not shared: English puts the verb first, and a
90
99
  * language that does not would otherwise be stuck with a prefix. */
91
100
  textDisclosure: { show: (label: string) => string; hide: (label: string) => string };
101
+ /** The info-popover trigger's screen-reader name on a heading — every level of
102
+ * `SectionHeading*`, and `CardHeaderTitle`, which names the same altitude. */
92
103
  sectionHeading: { info: string };
93
104
  /** `ErrorState`'s retry button. The kit owns the wording so "try again" is
94
105
  * phrased identically everywhere instead of hand-written per app. */
@@ -293,6 +304,12 @@ export const en: LoticsLocale = {
293
304
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
294
305
  ledger: { rowDetails: (label) => `${label} details` },
295
306
  stepper: { complete: "Complete step", progress: "Progress" },
307
+ stepProgress: {
308
+ complete: (total) => `Complete (${total}/${total})`,
309
+ stage: (name, index, total) => `${name} (${index}/${total})`,
310
+ none: (total) => `0/${total}`,
311
+ position: (index, total) => `${index} of ${total}`,
312
+ },
296
313
  checklist: { expand: "Show", collapse: "Hide" },
297
314
  textDisclosure: { show: (label) => `Show ${label}`, hide: (label) => `Hide ${label}` },
298
315
  sectionHeading: { info: "About this data" },
@@ -472,6 +489,12 @@ export const vi: LoticsLocale = {
472
489
  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" },
473
490
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
474
491
  stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
492
+ stepProgress: {
493
+ complete: (total) => `Hoàn tất (${total}/${total})`,
494
+ stage: (name, index, total) => `${name} (${index}/${total})`,
495
+ none: (total) => `0/${total}`,
496
+ position: (index, total) => `${index} trên ${total}`,
497
+ },
475
498
  checklist: { expand: "Mở", collapse: "Thu gọn" },
476
499
  textDisclosure: { show: (label) => `Xem ${label}`, hide: (label) => `Ẩn ${label}` },
477
500
  sectionHeading: { info: "Giải thích dữ liệu" },
@@ -18,6 +18,12 @@ export interface NumberInputProps {
18
18
  disabled?: boolean;
19
19
  testID?: string;
20
20
  accessibilityLabel?: string;
21
+ /** Hint text shown while the field is empty — a unit, a shape, an example
22
+ * ("0,00", "kg"). `InlineNumberInput` has always had one; without it here the
23
+ * only hint slot left on a `FormField` is `description`, which renders
24
+ * BETWEEN label and input and so drops this field's input a line below its
25
+ * neighbour's in a two-column form grid. */
26
+ placeholder?: string;
21
27
  /** The HOST paints the surface — drop this input's own border, fill, focus ring
22
28
  * AND horizontal padding, so the page draws one box and the text lands on the
23
29
  * host's inset rather than 8px further in. Set by an inline editor whose field
@@ -37,7 +43,7 @@ export interface NumberInputProps {
37
43
  * number in place on a record use `InlineNumberInput`.
38
44
  */
39
45
  export function NumberInput(props: NumberInputProps) {
40
- const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, seamless } = props;
46
+ const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, placeholder, seamless } = props;
41
47
  const binding = useFormField();
42
48
  const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
43
49
  const { focusVisible, focusProps } = useFocusRing({ always: true });
@@ -52,6 +58,7 @@ export function NumberInput(props: NumberInputProps) {
52
58
  aria-describedby={describedBy}
53
59
  aria-invalid={binding?.invalid || undefined}
54
60
  value={value ?? ""}
61
+ placeholder={placeholder}
55
62
  onChange={(e) =>
56
63
  e.target.value !== "" ? onValueChange(Number(e.target.value)) : onValueChange(null)
57
64
  }
@@ -423,10 +423,17 @@ export function ReferenceField(props: ReferenceFieldProps) {
423
423
  accessibilityLabel={`${t.change} — ${name}`}
424
424
  onPress={() => { setPeekOpen(false); onChange(); }}
425
425
  />
426
- {/* No fill the least-reached verb here, and the one whose
427
- result the reader is least likely to want by accident. */}
426
+ {/* Same resting surface as `change` beside it. It had NO
427
+ color which renders transparent, borderless and
428
+ undecorated, i.e. the hover-only affordance composition.md
429
+ bans: invisible to keyboard and touch, and a singleton
430
+ no-ground among grounded siblings, which a reader parses
431
+ before they read any label. Being the least-reached verb is
432
+ said by PLACEMENT — second in the left group, far from the
433
+ primary — not by drawing nothing. */}
428
434
  <Button
429
435
  title={t.clear}
436
+ color="secondary"
430
437
  accessibilityLabel={`${t.clear} — ${name}`}
431
438
  onPress={() => { setPeekOpen(false); onClear(); }}
432
439
  />
@@ -77,9 +77,21 @@ export interface SubsectionHeadingProps {
77
77
  style?: StyleProp<ViewStyle>;
78
78
  }
79
79
 
80
+ /**
81
+ * The narrowest a heading's TITLE column may be before its row wraps and the
82
+ * trailing slot (an ADD verb, a meta, a badge) drops to the next line.
83
+ *
84
+ * Needed because RN-Web resolves `flex: 1` to `flex-basis: 0%` with
85
+ * `min-width: 0`: with no floor, a title beside a button that holds its
86
+ * intrinsic width is the half that gives way, so the HEADING breaks — one word
87
+ * per line — while the verb beside it sits untouched. That is backwards. The
88
+ * title names the section; the verb is the thing that can move.
89
+ */
90
+ const HEADING_TITLE_MIN_WIDTH = 200;
91
+
80
92
  export function SubsectionHeading(props: SubsectionHeadingProps) {
81
93
  return (
82
- <View style={[{ flexDirection: "row", alignItems: "center", gap: 10 }, props.style]}>
94
+ <View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 10 }, props.style]}>
83
95
  {props.children}
84
96
  </View>
85
97
  );
@@ -116,7 +128,7 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
116
128
  // wrapper carries the `flex: 1` that lets a heading row push meta to its right
117
129
  // edge. Without the wrapper the description would land on the title's LINE.
118
130
  return (
119
- <View style={{ flex: 1, gap: 2 }}>
131
+ <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
120
132
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
121
133
  <Text level={level} size="lg" weight="semibold">
122
134
  {children}
@@ -173,7 +185,7 @@ export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps)
173
185
  const { children, description, icon, level = 4, info } = props;
174
186
  const words = useLoticsLocale();
175
187
  return (
176
- <View style={{ flex: 1, gap: 2 }}>
188
+ <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
177
189
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
178
190
  {icon ? <Icon name={icon} size={16} /> : null}
179
191
  <Text level={level} size="md" weight="semibold">
@@ -197,7 +209,7 @@ export interface SectionHeadingProps {
197
209
 
198
210
  export function SectionHeading(props: SectionHeadingProps) {
199
211
  return (
200
- <View style={[{ flexDirection: "row", alignItems: "center", gap: 12 }, props.style]}>
212
+ <View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12 }, props.style]}>
201
213
  {props.children}
202
214
  </View>
203
215
  );
@@ -229,7 +241,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
229
241
  // render `info` appears or disappears.
230
242
  const words = useLoticsLocale();
231
243
  return (
232
- <View style={{ flex: 1, gap: 2 }}>
244
+ <View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
233
245
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
234
246
  {icon ? <Icon name={icon} size={20} /> : null}
235
247
  <Text level={level} weight={weight} size="xl">
package/src/select.tsx CHANGED
@@ -8,8 +8,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
8
8
  import { OptionList } from "./option_list";
9
9
  import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
10
10
 
11
- export interface SelectProps<T extends string = string, MULTI extends boolean = false> {
12
- options?: (PickerOption<T> | undefined | false)[];
11
+ export interface SelectProps<T extends string = string, MULTI extends boolean = false, D = unknown> {
12
+ options?: (PickerOption<T, D> | undefined | false)[];
13
13
  placeholder?: string;
14
14
  /** Accessible name for the control. Required in spirit whenever there's no
15
15
  * visible label beside it — the selected text describes the value, not the control. */
@@ -17,15 +17,15 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
17
17
  /** How each OPTION renders in the menu (a colour-dot badge, a member chip).
18
18
  * Also the default for the trigger's selection display when `renderSelected`
19
19
  * is omitted. */
20
- renderOptionContent?: (option: PickerOption<T>) => ReactNode;
20
+ renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
21
21
  /** How each SELECTED item renders in the anchor — a render FUNCTION, not a preset.
22
22
  * Use `controls.remove` to detach it in place: render `<Chip onDismiss={remove}>`
23
23
  * for a removable chip box, an `OptionBadge`, or any custom display. Omit it for
24
24
  * a comma summary (single: the one value, `remove` clears it). The single seam
25
25
  * for "how the selection looks" — there's no `display` mode. */
26
- renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
26
+ renderSelected?: (item: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
27
27
  /** A single-line subtitle under each option's label in the menu. */
28
- getOptionDescription?: (option: PickerOption<T>) => string | undefined;
28
+ getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
29
29
  /** Show a search field in the menu to filter the options. */
30
30
  searchable?: boolean;
31
31
  /** Offer a "create" row when the query matches no option — picking it adds the
@@ -59,7 +59,7 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
59
59
  * select use the native `Picker`; to SEARCH a large/remote set with the input AS
60
60
  * the control (type-in-place, free text reflected in the field) use `Combobox`.
61
61
  */
62
- export function Select<T extends string, MULTI extends boolean = false>(props: SelectProps<T, MULTI>) {
62
+ export function Select<T extends string, MULTI extends boolean = false, D = unknown>(props: SelectProps<T, MULTI, D>) {
63
63
  const {
64
64
  testID,
65
65
  options = [],
@@ -139,7 +139,7 @@ export function Select<T extends string, MULTI extends boolean = false>(props: S
139
139
  field/footer OUTSIDE it. The default body ScrollView would be a redundant
140
140
  second scroll that also clips the autofocused search field's outset ring. */}
141
141
  <PopoverContent disableBodyScroll>
142
- <OptionList<T, MULTI>
142
+ <OptionList<T, MULTI, D>
143
143
  search={{ mode: searchable || allowCustom ? "internal" : "none" }}
144
144
  options={options}
145
145
  multi={multi}
@@ -163,18 +163,18 @@ export function Select<T extends string, MULTI extends boolean = false>(props: S
163
163
  );
164
164
  }
165
165
 
166
- function useSelectedItems<T extends string>(
166
+ function useSelectedItems<T extends string, D = unknown>(
167
167
  multi: boolean,
168
168
  value: T | T[] | undefined | null,
169
- options: (PickerOption<T> | undefined | false)[],
170
- ): PickerOption<T>[] {
169
+ options: (PickerOption<T, D> | undefined | false)[],
170
+ ): PickerOption<T, D>[] {
171
171
  return useMemo(() => {
172
172
  const optionsMap = new Map(
173
- options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
173
+ options.filter((opt): opt is PickerOption<T, D> => !!opt).map((opt) => [opt.value, opt]),
174
174
  );
175
175
  // A value not in `options` (e.g. an `allowCustom` creation) still shows —
176
176
  // fall back to a {value, label} so it renders.
177
- const resolve = (val: T): PickerOption<T> => optionsMap.get(val) ?? { value: val, label: val };
177
+ const resolve = (val: T): PickerOption<T, D> => optionsMap.get(val) ?? { value: val, label: val };
178
178
  if (multi) {
179
179
  const multiValue = Array.isArray(value) ? value : [];
180
180
  return multiValue.map(resolve);
@@ -187,7 +187,7 @@ function useSelectedItems<T extends string>(
187
187
  }, [multi, value, options]);
188
188
  }
189
189
 
190
- function SelectTrigger<T extends string>({
190
+ function SelectTrigger<T extends string, D = unknown>({
191
191
  ref,
192
192
  testID,
193
193
  open,
@@ -206,10 +206,10 @@ function SelectTrigger<T extends string>({
206
206
  open: boolean;
207
207
  style?: StyleProp<ViewStyle>;
208
208
  onPress: () => void;
209
- renderOptionContent?: (option: PickerOption<T>) => ReactNode;
210
- renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
209
+ renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
210
+ renderSelected?: (item: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
211
211
  onRemove?: (value: T) => void;
212
- selectedItems: PickerOption<T>[];
212
+ selectedItems: PickerOption<T, D>[];
213
213
  placeholder?: string;
214
214
  accessibilityLabel?: string;
215
215
  disabled?: boolean;
package/src/sequence.tsx CHANGED
@@ -77,8 +77,11 @@ export interface SequenceItemProps {
77
77
  role?: string;
78
78
  /** The item's editors / content. */
79
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. */
80
+ /** Swap with the item above. On an item that is editable at all, omitting
81
+ * this (or leaving it undefined on the first item) renders the control
82
+ * DISABLED rather than absent, so the column never changes width between
83
+ * items. Omit all three and the item is not editable: the control column
84
+ * goes away entirely — see the note on the component. */
82
85
  onMoveUp?: () => void;
83
86
  onMoveDown?: () => void;
84
87
  onRemove?: () => void;
@@ -98,6 +101,13 @@ export interface SequenceItemProps {
98
101
  * phone, and a list of three-to-six positions does not need the expressiveness.
99
102
  * They render even where they cannot act (first item, last item) so the row's
100
103
  * right edge never shifts between items.
104
+ *
105
+ * That steadiness is between items that CAN be edited. An item passing NO
106
+ * handler at all is not an edge case of editing, it is a read-only row, and it
107
+ * renders no control column: three permanently dead buttons on every row of a
108
+ * sequence nobody can reorder are dead affordances, which the kit's own
109
+ * reviewing gates count as a defect. A read-only `Sequence` is a legitimate and
110
+ * common shape — an ordered list of legs already run, a route as recorded.
101
111
  */
102
112
  export function SequenceItem(props: SequenceItemProps) {
103
113
  const { role, children, onMoveUp, onMoveDown, onRemove, accessibilityName, roleWidth = 74 } = props;
@@ -106,6 +116,10 @@ export function SequenceItem(props: SequenceItemProps) {
106
116
  const first = pos == null || pos.index === 0;
107
117
  const last = pos == null || pos.index === pos.count - 1;
108
118
  const named = (verb: string) => (accessibilityName ? `${verb} ${accessibilityName}` : verb);
119
+ // Any ONE handler makes the item editable and brings the whole cluster, so a
120
+ // row that can be removed but not reordered still holds the column steady
121
+ // against its neighbours. Only "no handler at all" drops it.
122
+ const editable = onMoveUp != null || onMoveDown != null || onRemove != null;
109
123
  return (
110
124
  <View style={styles.item}>
111
125
  {/* THE RAIL — a leading segment, the dot on the control line, then a
@@ -126,29 +140,31 @@ export function SequenceItem(props: SequenceItemProps) {
126
140
  </View>
127
141
  ) : null}
128
142
  <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>
143
+ {editable ? (
144
+ <View style={styles.controls}>
145
+ <IconButton
146
+ icon="chevron-up"
147
+ tooltip={labels.moveUp}
148
+ accessibilityLabel={named(labels.moveUp)}
149
+ disabled={first || onMoveUp == null}
150
+ onPress={() => onMoveUp?.()}
151
+ />
152
+ <IconButton
153
+ icon="chevron-down"
154
+ tooltip={labels.moveDown}
155
+ accessibilityLabel={named(labels.moveDown)}
156
+ disabled={last || onMoveDown == null}
157
+ onPress={() => onMoveDown?.()}
158
+ />
159
+ <IconButton
160
+ icon="x"
161
+ tooltip={labels.remove}
162
+ accessibilityLabel={named(labels.remove)}
163
+ disabled={onRemove == null}
164
+ onPress={() => onRemove?.()}
165
+ />
166
+ </View>
167
+ ) : null}
152
168
  </View>
153
169
  </View>
154
170
  );
@@ -3,6 +3,7 @@ import { colors } from "./colors";
3
3
  import { Text } from "./text";
4
4
  import { Eyebrow } from "./eyebrow";
5
5
  import { useTooltip } from "./tooltip";
6
+ import { useLoticsLocale } from "./locale";
6
7
 
7
8
  export interface StepProgressProps {
8
9
  /** The stages: pass the NAMES (enables the built-in "In (4/7)" caption
@@ -50,6 +51,7 @@ export function StepProgress(props: StepProgressProps) {
50
51
  // much is done", the same claim the one filled button makes. Still a prop, so a
51
52
  // caller that means a specific hue (a status, a series) passes one.
52
53
  const { steps, current, color = colors.primary, title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
54
+ const words = useLoticsLocale();
53
55
  const captionColor = captionTone === "danger" ? "danger" : "muted";
54
56
  const names = typeof steps === "number" ? null : steps;
55
57
  const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
@@ -59,10 +61,10 @@ export function StepProgress(props: StepProgressProps) {
59
61
  label ??
60
62
  (names
61
63
  ? isComplete
62
- ? `Complete (${count}/${count})`
64
+ ? words.stepProgress.complete(count)
63
65
  : safe >= 0
64
- ? `${names[safe]} (${safe + 1}/${count})`
65
- : `0/${count}`
66
+ ? words.stepProgress.stage(names[safe], safe + 1, count)
67
+ : words.stepProgress.none(count)
66
68
  : undefined);
67
69
 
68
70
  // `progressbar` IS right here — the segments are decoration over one quantity,
@@ -73,7 +75,7 @@ export function StepProgress(props: StepProgressProps) {
73
75
  const bar = (
74
76
  <View
75
77
  accessibilityRole="progressbar"
76
- accessibilityLabel={accessibilityLabel ?? caption ?? `${Math.max(0, safe + 1)} of ${count}`}
78
+ accessibilityLabel={accessibilityLabel ?? caption ?? words.stepProgress.position(Math.max(0, safe + 1), count)}
77
79
  aria-valuenow={isComplete ? count : Math.max(0, safe + 1)}
78
80
  aria-valuemin={0}
79
81
  aria-valuemax={count}
package/src/table_fit.ts CHANGED
@@ -21,9 +21,17 @@ export interface TableFitColumn {
21
21
  /** Fixed width in px; omit for a flexible column. */
22
22
  width?: number;
23
23
  /** Drop precedence when the container can't fit every column: HIGHER numbers
24
- * drop first, ties drop right-to-left. Default = the column's index (so an
25
- * unannotated register sheds from the right). The FIRST column is the row's
26
- * identity — it never drops. */
24
+ * drop first, ties drop right-to-left. The FIRST column is the row's identity
25
+ * it never drops.
26
+ *
27
+ * An unannotated column defaults to `columns.length + index`, which is above
28
+ * every hand-written priority rather than interleaved with them, so an
29
+ * unannotated register still sheds right-to-left AND an explicit priority is
30
+ * strictly safer than none. Defaulting to the bare index made annotation
31
+ * actively harmful: marking your most important column `priority: 1` TIED it
32
+ * with the unannotated column at index 1, and the right-to-left tie-break
33
+ * then dropped yours — losing the amount column, the fact the register exists
34
+ * for, on a screen that reviewed as correctly annotated. */
27
35
  priority?: number;
28
36
  }
29
37
 
@@ -59,10 +67,12 @@ export const ROW_HEIGHT = 72;
59
67
  */
60
68
  export const ROW_GUTTER = 0;
61
69
  const ROW_H_PADDING = ROW_GUTTER * 2;
62
- /** Fit-math width a flexible column needs to stay usable — below this the flex
63
- * column is crushed to ellipsis soup, so it counts as this wide when deciding
64
- * what fits. Layout still lets it grow (`flex`) or shrink (`minWidth: 0`). */
65
- const FLEX_MIN_WIDTH = 120;
70
+ /** Width a flexible column needs to stay usable — below this it is crushed to
71
+ * ellipsis soup. `Table` counts a flex column as this wide when deciding what
72
+ * fits; `DataGrid`, which sheds nothing, uses it as the column's hard floor so
73
+ * the squeeze overflows the grid instead of erasing its identity column.
74
+ * Exported so the two cannot drift. */
75
+ export const FLEX_MIN_WIDTH = 120;
66
76
  /** Fewer side-by-side columns than this stops being a register — stack instead. */
67
77
  const MIN_VISIBLE_COLUMNS = 2;
68
78
 
@@ -91,7 +101,7 @@ export function computeTableFit(
91
101
  };
92
102
 
93
103
  const allKeys = new Set(columns.map((c) => c.key));
94
- const priorityOf = (c: TableFitColumn) => c.priority ?? columns.indexOf(c);
104
+ const priorityOf = (c: TableFitColumn) => c.priority ?? columns.length + columns.indexOf(c);
95
105
  // Column 0 never enters the drop order — it's the row's identity.
96
106
  const dropOrder = columns
97
107
  .slice(1)
package/src/tabs.tsx CHANGED
@@ -12,6 +12,19 @@ export interface TabOption<T extends string> {
12
12
  /** A small status dot before the label — set only when the tab's area needs
13
13
  * attention (a blocker / missing item). Omit for the resting state. */
14
14
  status?: ColorName;
15
+ /** How many rows the tab's band holds, shown after the label.
16
+ *
17
+ * A PROP, never a count formatted into `label`. A tab band partitions a
18
+ * register exactly as `TableGroup` does, and that component states its size
19
+ * the same way for the same reason: a hand-built `"Trên tàu (4)"` is a second
20
+ * copy of a number the screen already computes, and it is the copy that goes
21
+ * stale. Pressing the tab is the filter, so the count is also the reason to
22
+ * press it.
23
+ *
24
+ * Yours to pass because it is rarely the child count — a paged or filtered
25
+ * band counts what matched, not what rendered. Omit when every band is
26
+ * always whole. */
27
+ count?: number;
15
28
  }
16
29
 
17
30
  interface TabsProps<T extends string> {
@@ -113,6 +126,12 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
113
126
  >
114
127
  {option.label}
115
128
  </Text>
129
+ {option.count != null ? (
130
+ <Text color="zinc-500" tabular userSelect="none">
131
+ {option.count}
132
+ </Text>
133
+ ) : null}
134
+
116
135
  </View>
117
136
  );
118
137
 
@@ -45,6 +45,24 @@ interface TextInputFieldProps extends RNTextInputProps {
45
45
  * because the host already spent `CONTROL_TEXT_INSET` on it and two insets
46
46
  * stacked is a visible jump to the right on focus. */
47
47
  seamless?: boolean;
48
+ /**
49
+ * Frameless at rest — no ground, no visible edge — for a field that must read
50
+ * as its own value until touched: a table cell, a title in place. The edge
51
+ * arrives on hover and the ring on focus, exactly as in the framed variant.
52
+ *
53
+ * It is a PROP rather than a `style` the caller passes because of the order
54
+ * below. Hover is applied before `style`, so a caller-supplied
55
+ * `borderColor: "transparent"` landed on top of it and erased the hover edge
56
+ * on every bare field in the kit — leaving a control whose entire affordance
57
+ * was that edge with no affordance at all, and no way to discover it was a
58
+ * control short of clicking. Nothing measured wrong: the field had a real
59
+ * focus ring, a real role, correct type and spacing, and the hover rule was
60
+ * right there in the array being silently overwritten one line later.
61
+ *
62
+ * Distinct from `seamless`, which surrenders the surface to a HOST that draws
63
+ * it. Bare has no host — it draws its own frame, just not at rest.
64
+ */
65
+ variant?: "framed" | "bare";
48
66
  // DOM-only ARIA attrs not declared on React Native's TextInputProps. They
49
67
  // are forwarded verbatim to the underlying web input.
50
68
  "aria-controls"?: string;
@@ -63,6 +81,7 @@ export function TextInputField(props: TextInputFieldProps) {
63
81
  const {
64
82
  style,
65
83
  seamless,
84
+ variant = "framed",
66
85
  icon,
67
86
  clearable,
68
87
  onClear,
@@ -202,8 +221,20 @@ export function TextInputField(props: TextInputFieldProps) {
202
221
  icon && styles.withIcon,
203
222
  showClear && styles.withClear,
204
223
  showShortcut && shortcutWidth > 0 && { paddingRight: SHORTCUT_INSET + shortcutWidth },
205
- hovered && editable && { borderColor: HOVER_BORDER },
224
+ // RESTING FRAME first, so a caller's `style` can still override it.
225
+ variant === "bare" && styles.bare,
206
226
  style,
227
+ // ...and HOVER after `style`, which is the grammar's rule for fields
228
+ // (composition.md § the surface table) and was inverted here. A caller
229
+ // passing a resting `borderColor` — which is exactly how `bare` used
230
+ // to be expressed — silently erased the hover edge one line after it
231
+ // was computed. Order is the whole bug: every property was correct.
232
+ //
233
+ // Still BEFORE the last two. The ring is a shadow, so it does not
234
+ // compete; `seamless` must outrank hover, because there the border
235
+ // belongs to the host shell and the input drawing its own would put
236
+ // two edges on one field.
237
+ hovered && editable && { borderColor: HOVER_BORDER },
207
238
  focusVisible && !seamless && { boxShadow: FOCUS_RING },
208
239
  seamless && styles.seamless,
209
240
  ]}
@@ -277,6 +308,9 @@ const styles = StyleSheet.create({
277
308
  // Safe for height because the box is `border-box`: dropping the border changes
278
309
  // the content box, never the field's 40px outer size.
279
310
  seamless: { borderColor: "transparent", borderWidth: 0, backgroundColor: "transparent", paddingHorizontal: 0 },
311
+ // Nothing at rest — but the 1px border STAYS, transparent, so the hover edge
312
+ // costs no reflow when it arrives.
313
+ bare: { borderColor: "transparent", backgroundColor: "transparent" },
280
314
  disabled: {
281
315
  color: colors.zinc["400"],
282
316
  outlineStyle: "none" as unknown as "solid",