@lotics/ui 11.8.10 → 12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.8.10",
3
+ "version": "12.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -1,15 +1,16 @@
1
1
  import { IconButton } from "./icon_button";
2
- import { Pressable, View } from "react-native";
2
+ import { View } from "react-native";
3
3
  import { Icon } from "./icon";
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
+ import { PressableHighlight } from "./pressable_highlight";
6
7
  import { useLoticsLocale } from "./locale";
7
8
 
8
9
  interface BackButtonProps {
9
10
  onPress: () => void;
10
- /** Names where back GOES ("Orders", "Danh sách"). Renders beside the glyph as
11
- * part of the SAME control — a label you can't press is a lie. Omit for the
12
- * bare glyph. */
11
+ /** Names where back GOES ("Orders", "Danh sách"). Renders beside the circular
12
+ * glyph as part of the SAME control — a label you can't press is a lie. Omit
13
+ * for the bare glyph. */
13
14
  label?: string;
14
15
  /** Accessible name. Defaults to `label`, else the locale's `nav.back`
15
16
  * ("Back" / "Quay lại"). */
@@ -19,46 +20,38 @@ interface BackButtonProps {
19
20
  export function BackButton(props: BackButtonProps) {
20
21
  const locale = useLoticsLocale();
21
22
  const { onPress, label, accessibilityLabel = label ?? locale.nav.back } = props;
22
- // Bare glyph — the circular icon IS the control.
23
+ // Bare — the circular glyph IS the control.
23
24
  if (label === undefined) {
24
25
  return (
25
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
26
- <IconButton
27
- icon="chevron-left"
28
- size="lg"
29
- color="secondary"
30
- accessibilityLabel={accessibilityLabel}
31
- onPress={onPress}
32
- style={{ alignSelf: "flex-start" }}
33
- />
34
- </View>
26
+ <IconButton
27
+ icon="chevron-left"
28
+ size="lg"
29
+ color="secondary"
30
+ accessibilityLabel={accessibilityLabel}
31
+ onPress={onPress}
32
+ style={{ alignSelf: "flex-start" }}
33
+ />
35
34
  );
36
35
  }
37
- // Labelled — ONE pressable over glyph + label, so the words are a target too
38
- // (an IconButton beside a Text would be a control next to unclickable prose).
36
+ // Labelled — the SAME circular glyph, the destination named beside it, ONE
37
+ // pressable pill over both (words you can't press are a lie). The disc
38
+ // mirrors IconButton lg/secondary (40px, zinc-100, 20px zinc-900 glyph); the
39
+ // pill takes PressableHighlight's default hover/press wash + focus ring.
39
40
  return (
40
- <Pressable
41
+ <PressableHighlight
42
+ focusRing
41
43
  accessibilityRole="button"
42
44
  accessibilityLabel={accessibilityLabel}
45
+ userSelect="none"
43
46
  onPress={onPress}
44
- style={({ pressed, hovered }) => [
45
- {
46
- flexDirection: "row",
47
- alignItems: "center",
48
- gap: 6,
49
- alignSelf: "flex-start",
50
- paddingVertical: 6,
51
- paddingLeft: 6,
52
- paddingRight: 10,
53
- borderRadius: 8,
54
- },
55
- (hovered || pressed) && { backgroundColor: colors.zinc[100] },
56
- ]}
47
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", borderRadius: 999, paddingRight: 14 }}
57
48
  >
58
- <Icon name="chevron-left" size={16} color={colors.zinc[500]} />
49
+ <View style={{ width: 40, height: 40, borderRadius: 999, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
50
+ <Icon name="chevron-left" size={20} color={colors.zinc[900]} />
51
+ </View>
59
52
  <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
60
53
  {label}
61
54
  </Text>
62
- </Pressable>
55
+ </PressableHighlight>
63
56
  );
64
57
  }
package/src/checklist.tsx CHANGED
@@ -3,11 +3,13 @@ import { StyleSheet, View } from "react-native";
3
3
  import { ActionMenu, type ActionMenuItem } from "./action_menu";
4
4
 
5
5
  // The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
6
- // (row height, ring/title alignment, one trailing column width) while the
7
- // CONTENT stays composed: a `CheckCircle` (live or read-only) in `control`,
8
- // a struck `InlineTextInput` or plain `Text` as the title, an
9
- // `InlineMemberSelect` or nothing in `trailing`, and `menu` for the row's
10
- // options (Delete lives BEHIND the menu indirection that prevents
6
+ // (row height, control/title alignment, the meta/expansion indent, one
7
+ // trailing column width) while the CONTENT stays composed: tasks (a
8
+ // `CheckCircle` in `control`, a struck `InlineTextInput` title) and PICKER
9
+ // rows (a `CheckboxInput` in `control` set `controlWidth={24}` with a
10
+ // readiness `meta` line and a fill editor in `expansion`) are the same
11
+ // anatomy. `menu` carries the row's ⋯ options (Delete lives BEHIND the menu
12
+ // — indirection that prevents
11
13
  // accidental destructive taps). Suggested tasks are NOT rows: offer the record
12
14
  // type's commons as `SuggestionChip`s under the list (tap = materialize,
13
15
  // ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
@@ -27,21 +29,26 @@ import { ActionMenu, type ActionMenuItem } from "./action_menu";
27
29
 
28
30
  interface ChecklistContextValue {
29
31
  trailingWidth?: number;
32
+ controlWidth: number;
30
33
  }
31
34
 
32
- const ChecklistContext = createContext<ChecklistContextValue>({});
35
+ const ChecklistContext = createContext<ChecklistContextValue>({ controlWidth: 20 });
33
36
 
34
37
  export interface ChecklistProps {
35
38
  /** Fixed width of every row's trailing cell (an assignee select) — set it
36
39
  * once so the column lines up; omit when rows carry no trailing. */
37
40
  trailingWidth?: number;
41
+ /** Width of every row's leading control — `CheckCircle` 20 (the default),
42
+ * `CheckboxInput` 24. The `meta`/`expansion` indent derives from it, so
43
+ * the second line always sits on the title's text edge. */
44
+ controlWidth?: number;
38
45
  children: ReactNode;
39
46
  }
40
47
 
41
48
  export function Checklist(props: ChecklistProps) {
42
- const { trailingWidth, children } = props;
49
+ const { trailingWidth, controlWidth = 20, children } = props;
43
50
  return (
44
- <ChecklistContext.Provider value={{ trailingWidth }}>
51
+ <ChecklistContext.Provider value={{ trailingWidth, controlWidth }}>
45
52
  <View style={styles.list}>{children}</View>
46
53
  </ChecklistContext.Provider>
47
54
  );
@@ -58,6 +65,10 @@ interface ChecklistRowBaseProps {
58
65
  * carrier"). The ⋯ column aligns only when every row in the list carries
59
66
  * a menu — keep its presence uniform per list. */
60
67
  menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
68
+ /** A BLOCK slot under the row (below `meta`), indented to the title's text
69
+ * edge — a transient inline fill editor, a drill-down. Render it only
70
+ * while open; the list's geometry (the indent) is owned here. */
71
+ expansion?: ReactNode;
61
72
  }
62
73
 
63
74
  /** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
@@ -77,11 +88,14 @@ export type ChecklistRowProps = ChecklistRowBaseProps &
77
88
  }
78
89
  );
79
90
 
80
- /** One task line: control · title (flex) · the aligned trailing cell · ⋯,
81
- * with an optional indented `meta` line below for narrow surfaces. */
91
+ /** One checklist line: control · title (flex) · the aligned trailing cell · ⋯,
92
+ * with an optional indented `meta` line below for narrow surfaces and an
93
+ * optional `expansion` block under that (both sit on the title's text edge). */
82
94
  export function ChecklistRow(props: ChecklistRowProps) {
83
- const { control, children, trailing, menu, meta } = props;
84
- const { trailingWidth } = useContext(ChecklistContext);
95
+ const { control, children, trailing, menu, meta, expansion } = props;
96
+ const { trailingWidth, controlWidth } = useContext(ChecklistContext);
97
+ // Indent past the control + the row gap so meta/expansion align with the title.
98
+ const indent = controlWidth + 12;
85
99
  return (
86
100
  <View>
87
101
  <View style={styles.row}>
@@ -90,7 +104,8 @@ export function ChecklistRow(props: ChecklistRowProps) {
90
104
  {trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
91
105
  {menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
92
106
  </View>
93
- {meta != null ? <View style={styles.meta}>{meta}</View> : null}
107
+ {meta != null ? <View style={[styles.meta, { paddingLeft: indent }]}>{meta}</View> : null}
108
+ {expansion != null ? <View style={[styles.expansion, { paddingLeft: indent }]}>{expansion}</View> : null}
94
109
  </View>
95
110
  );
96
111
  }
@@ -99,6 +114,6 @@ const styles = StyleSheet.create({
99
114
  list: { gap: 4 },
100
115
  row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
101
116
  title: { flex: 1 },
102
- // Indent past the ring (20) + the row gap (12) so meta aligns with the title.
103
- meta: { paddingLeft: 32, paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
117
+ meta: { paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
118
+ expansion: { paddingTop: 2, paddingBottom: 6 },
104
119
  });
@@ -273,8 +273,9 @@ function CommentRow(props: CommentRowProps) {
273
273
 
274
274
  /**
275
275
  * The comment list — one author/avatar/timestamp/content/files row per comment,
276
- * oldest first, with an author-only edit/delete menu. Pair it with a composer
277
- * (`CommentComposer`, or a richer consumer-owned one) for the full thread.
276
+ * oldest first, with an author-only edit/delete menu. Pair it with THE kit
277
+ * `Composer` for the full thread (attach = `actionsButton` + `files` slots) —
278
+ * never a bespoke comment box.
278
279
  */
279
280
  export function CommentList(props: CommentListProps) {
280
281
  const labels = { ...DEFAULT_LABELS, ...props.labels };
@@ -316,63 +317,3 @@ export function CommentList(props: CommentListProps) {
316
317
  );
317
318
  }
318
319
 
319
- export interface CommentComposerProps {
320
- /** Submit a new comment. Cleared on success; left intact on throw so the draft survives. */
321
- onSubmit: (content: string) => void | Promise<void>;
322
- placeholder: string;
323
- sendLabel: string;
324
- /** Disable input + send (e.g. comments unavailable for an anonymous viewer). */
325
- disabled?: boolean;
326
- /** Extra controls left of the send button — e.g. an attach-file button. */
327
- actions?: ReactNode;
328
- }
329
-
330
- /**
331
- * A minimal text comment composer for consumers without a richer one of their
332
- * own (the product uses its file-capable chat composer instead). Multiline
333
- * input + a send button; attachments are opt-in via the `actions` slot.
334
- */
335
- export function CommentComposer(props: CommentComposerProps) {
336
- const { onSubmit, placeholder, sendLabel, disabled, actions } = props;
337
- const [content, setContent] = useState("");
338
- const [sending, setSending] = useState(false);
339
-
340
- const send = async () => {
341
- const text = content.trim();
342
- if (!text || sending || disabled) return;
343
- setSending(true);
344
- try {
345
- await onSubmit(text);
346
- setContent("");
347
- } catch {
348
- // Keep the draft so the user can retry; the data layer surfaces the
349
- // failure (the optimistic comment rolls back). Don't propagate.
350
- } finally {
351
- setSending(false);
352
- }
353
- };
354
-
355
- return (
356
- <View>
357
- <TextInputField
358
- value={content}
359
- onChangeText={setContent}
360
- placeholder={placeholder}
361
- editable={!disabled}
362
- multiline
363
- numberOfLines={2}
364
- />
365
- <Spacer size={8} />
366
- <View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end", alignItems: "center" }}>
367
- {actions}
368
- <Button
369
- title={sendLabel}
370
- color="primary"
371
- disabled={disabled || !content.trim() || sending}
372
- loading={sending}
373
- onPress={send}
374
- />
375
- </View>
376
- </View>
377
- );
378
- }
@@ -1,8 +1,7 @@
1
1
  import { createContext, ReactNode, useContext, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
4
- import { InfoPopover } from "./info_popover";
5
- import { useLoticsLocale } from "./locale";
4
+ import { getInputLineHeight } from "./text_utils";
6
5
  import { Text } from "./text";
7
6
 
8
7
  interface DetailTableColumns {
@@ -105,11 +104,25 @@ export interface DetailRowProps {
105
104
  labelSize?: "xs" | "sm";
106
105
  /** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
107
106
  minHeight?: number;
108
- /** An popover hugging the label a short "what this field means" gloss,
109
- * the row-label member of the one `info` vocabulary
110
- * (`CardHeaderTitle` / `SectionHeadingTitle` / `SubsectionHeadingTitle`).
111
- * Pass `undefined` to hide the (a caller can gate it on its own state). */
112
- info?: string;
107
+ /** PERSISTENT guidance what to enter, or the rule that applies ("Printed
108
+ * on every document"). Renders under the VALUE (the label column has no
109
+ * room for prose); stacked mode mirrors `FormField` (label · description ·
110
+ * control). Always visible field annotations are EXPLICIT, never a
111
+ * hidden ⓘ gloss. Invalid STATE → `error` or a co-located `Callout`. Same
112
+ * name, same meaning as `FormField.description`. */
113
+ description?: string;
114
+ /** Field-level failure, under the value in danger ink with `FormField`'s
115
+ * alert semantics (announced on appearance). For CONSUMER-validated state —
116
+ * the `Inline*` editors already render their own transient save errors;
117
+ * don't wire both to the same failure. */
118
+ error?: string;
119
+ /** The value renders as FLAT text (an `InlineStatic`, a plain `Text`) rather
120
+ * than a control-height chip. Annotations then tuck up by the control
121
+ * band's slack, so the perceived gap under the TEXT equals the gap under a
122
+ * chip — without it, the band's invisible bottom half reads as a hole
123
+ * between a static value and its description. Row height and label
124
+ * centering are unchanged. */
125
+ flat?: boolean;
113
126
  }
114
127
 
115
128
  /**
@@ -123,21 +136,42 @@ export interface DetailRowProps {
123
136
  */
124
137
  export function DetailRow(props: DetailRowProps) {
125
138
  const table = useContext(DetailTableContext);
126
- const { label, children, trailing, labelSize = "sm", info } = props;
139
+ const { label, children, trailing, labelSize = "sm", description, error, flat } = props;
127
140
  const labelWidth = props.labelWidth ?? table?.labelWidth;
128
141
  const minHeight = props.minHeight ?? table?.minHeight ?? 28;
129
142
  const form = labelWidth != null;
130
- const words = useLoticsLocale();
131
- // The ⓘ hugs the label text (flexShrink keeps long labels from pushing it
132
- // out of the cell) the label cell keeps its exact width either way.
133
- const infoLabel = (cellStyle: StyleProp<ViewStyle>) => (
134
- <View style={[styles.labelWithInfo, cellStyle]}>
135
- <Text size={labelSize} color="muted" numberOfLines={1} style={styles.labelShrink}>
143
+ // Annotations (description / error) live UNDER THE VALUE — the row grows,
144
+ // so the label + trailing center against the CONTROL LINE, not the block.
145
+ const annotated = description != null || error != null;
146
+ // A FLAT value (no painted chip) centers its text in the control band,
147
+ // leaving invisible slack below it — tuck the annotations up by that slack
148
+ // so the perceived gap matches a chip row's. Horizontal mode only (stacked
149
+ // mode has no band; it mirrors FormField's order).
150
+ const flatTuck = flat && annotated ? -Math.max(0, Math.round((minHeight - getInputLineHeight(false)) / 2)) : 0;
151
+ const labelCell = (cellStyle: StyleProp<ViewStyle>) => (
152
+ <View style={[styles.labelCell, { minHeight }, cellStyle]}>
153
+ <Text size={labelSize} color="muted" numberOfLines={1}>
136
154
  {label}
137
155
  </Text>
138
- <InfoPopover text={info ?? ""} accessibilityLabel={words.sectionHeading.info} />
139
156
  </View>
140
157
  );
158
+ // Error first (adjacent to the control that failed, FormField's alert
159
+ // semantics), then the guidance. Indented to the inline chip's OWN text
160
+ // inset (paddingHorizontal 8), so annotations align with the value's text.
161
+ const annotations = annotated ? (
162
+ <>
163
+ {error != null ? (
164
+ <Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite" style={styles.annotation}>
165
+ {error}
166
+ </Text>
167
+ ) : null}
168
+ {description != null ? (
169
+ <Text size="xs" color="muted" style={styles.annotation}>
170
+ {description}
171
+ </Text>
172
+ ) : null}
173
+ </>
174
+ ) : null;
141
175
  if (table?.stacked) {
142
176
  // Stacked mode wears the FORM grammar: the label renders exactly like a
143
177
  // `FormField` label (medium, default ink), so a narrow record surface
@@ -146,47 +180,54 @@ export function DetailRow(props: DetailRowProps) {
146
180
  // inline editor self-persists — only the look converges.
147
181
  return (
148
182
  <View style={styles.stackedRow}>
149
- {info != null ? (
150
- <View style={styles.labelWithInfo}>
151
- <Text weight="medium" numberOfLines={1} style={styles.labelShrink}>
152
- {label}
153
- </Text>
154
- <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} />
155
- </View>
156
- ) : (
157
- <Text weight="medium" numberOfLines={1}>
158
- {label}
159
- </Text>
160
- )}
183
+ <Text weight="medium" numberOfLines={1}>
184
+ {label}
185
+ </Text>
186
+ {/* stacked wears the FORM grammar exactly: label · description ·
187
+ control · error (the `FormField` order) */}
188
+ {description != null ? <Text color="muted">{description}</Text> : null}
161
189
  <View style={[styles.stackedValueRow, { minHeight }]}>
162
190
  <View style={styles.value}>{children}</View>
163
191
  {trailing != null ? trailing : null}
164
192
  </View>
193
+ {error != null ? (
194
+ <Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
195
+ {error}
196
+ </Text>
197
+ ) : null}
165
198
  </View>
166
199
  );
167
200
  }
201
+ // ONE alignment law for every row: the row TOP-ALIGNS and each cell (label ·
202
+ // control · trailing) centers within the first CONTROL LINE (`minHeight`) —
203
+ // a tall value block (a multi-control stack, annotations) never drags the
204
+ // label or trailing toward the block's center.
168
205
  return (
169
206
  <View style={[styles.row, { minHeight }]}>
170
- {info != null ? (
171
- infoLabel(form ? { width: labelWidth } : styles.flexLabel)
207
+ {labelCell(form ? { width: labelWidth } : styles.flexLabel)}
208
+ {form ? (
209
+ <View style={[styles.value, annotated && styles.valueStack]}>
210
+ <View style={[styles.controlLine, { minHeight }]}>{children}</View>
211
+ {flatTuck !== 0 && annotations != null ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
212
+ </View>
172
213
  ) : (
173
- <Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
174
- {label}
175
- </Text>
214
+ <View style={annotated ? styles.valueStack : undefined}>
215
+ <View style={[styles.controlLine, { minHeight }]}>{children}</View>
216
+ {flatTuck !== 0 && annotations != null ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
217
+ </View>
176
218
  )}
177
- {form ? <View style={styles.value}>{children}</View> : children}
178
219
  {table?.trailingWidth != null ? (
179
- <View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
220
+ <View style={[styles.trailingCell, { width: table.trailingWidth, minHeight }]}>{trailing}</View>
180
221
  ) : trailing != null ? (
181
- trailing
222
+ <View style={[styles.trailingCell, { minHeight }]}>{trailing}</View>
182
223
  ) : null}
183
224
  </View>
184
225
  );
185
226
  }
186
227
 
187
228
  const styles = StyleSheet.create({
188
- // 6px between rows — the zinc-50 editor chips need visible separation.
189
- table: { gap: 6 },
229
+ // 8px between rows — the zinc-50 editor chips need visible separation.
230
+ table: { gap: 8 },
190
231
  // Stacked (narrow) mode: label-over-value blocks need more air between rows.
191
232
  tableStacked: { gap: 14 },
192
233
  unmeasured: { opacity: 0 },
@@ -198,12 +239,17 @@ const styles = StyleSheet.create({
198
239
  },
199
240
  row: {
200
241
  flexDirection: "row",
201
- alignItems: "center",
242
+ alignItems: "flex-start",
202
243
  gap: 12,
203
244
  },
204
245
  flexLabel: { flex: 1 },
205
- labelWithInfo: { flexDirection: "row", alignItems: "center", gap: 4 },
206
- labelShrink: { flexShrink: 1 },
246
+ labelCell: { justifyContent: "center" },
247
+ // The first line of the value cell — the control centers in it exactly like
248
+ // the label and trailing cells do.
249
+ controlLine: { justifyContent: "center" },
250
+ valueStack: { gap: 2 },
251
+ // Aligns annotation text with the inline chip's text (its 8px inset).
252
+ annotation: { paddingLeft: 8 },
207
253
  // Form mode (labelWidth set): the value column FILLS the row so every inline
208
254
  // editor spans the SAME width — and none jumps wider when it swaps to the
209
255
  // (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
@@ -334,6 +334,11 @@ const styles = StyleSheet.create({
334
334
  borderColor: "transparent",
335
335
  backgroundColor: colors.zinc[50],
336
336
  paddingHorizontal: 8,
337
+ // Vertical padding matters only when the display content is TALLER than
338
+ // one line (a rich select showing title + description at rest): minHeight
339
+ // still governs single-line rows, so those render pixel-identical, while
340
+ // multi-line content gets breathing room instead of touching the edges.
341
+ paddingVertical: 6,
337
342
  flexDirection: "row",
338
343
  alignItems: "center",
339
344
  gap: 6,
@@ -10,7 +10,7 @@ import { ActivityIndicator } from "./activity_indicator";
10
10
  import { type InlineEditBackground, InlineEditView } from "./inline_edit";
11
11
  import { useLoticsLocale } from "./locale";
12
12
 
13
- export interface InlineSelectProps<T extends string> {
13
+ export interface InlineSelectProps<T extends string, D = unknown> {
14
14
  value: T | null;
15
15
  onSave: (next: T) => void | Promise<void>;
16
16
  /** Unset the field to empty. Provide it to make the value clearable: a compact
@@ -18,15 +18,15 @@ export interface InlineSelectProps<T extends string> {
18
18
  * separate from `onSave` (whose next is a non-null `T`) so an app opts in
19
19
  * without every caller having to handle null. */
20
20
  onClear?: () => void | Promise<void>;
21
- options: PickerOption<T>[];
21
+ options: PickerOption<T, D>[];
22
22
  /** Custom option content in the dropdown (icon + label, two-line, a badge…).
23
23
  * Omit for a plain label list — both render through the same `OptionList`. */
24
- renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
24
+ renderOptionContent?: (option: PickerOption<T, D>) => React.ReactNode;
25
25
  /** Render the SELECTED value in the resting view as a node (an avatar chip, a
26
26
  * colored badge) instead of its plain label. Falls back to `renderOptionContent`
27
27
  * (so the trigger renders like the options), then the plain label, when omitted;
28
28
  * the empty state always shows the placeholder. */
29
- renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
29
+ renderSelected?: (selected: PickerOption<T, D>) => React.ReactNode;
30
30
  placeholder?: string;
31
31
  disabled?: boolean;
32
32
  accessibilityLabel?: string;
@@ -43,7 +43,7 @@ export interface InlineSelectProps<T extends string> {
43
43
  * to the view, so the row never changes height. Picking commits; dismissing reverts.
44
44
  * Pass `renderOptionContent` for rich options, or omit it for a plain label list.
45
45
  */
46
- export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
46
+ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
47
47
  const { value, onSave, onClear, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
48
48
  const labels = useLoticsLocale().inline;
49
49
  const [open, setOpen] = useState(false);
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import { View } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { CONTROL_RADIUS } from "./control_surface";
5
5
  import { Text } from "./text";
@@ -148,10 +148,19 @@ function RadioOption<T extends string | number | symbol>(
148
148
  >
149
149
  {selected && <Icon name="check" size={compact ? 14 : 24} color={getTextColor("inverted")} />}
150
150
  </View>
151
- <View>
151
+ {/* The text column is width-CONSTRAINED so a long description WRAPS
152
+ inside the pressable (unconstrained, its intrinsic width overflows
153
+ the hover/press surface). Column rows take the full width; compact
154
+ row chips keep hugging their content. */}
155
+ <View style={compact ? styles.textCompact : styles.text}>
152
156
  <Text size={compact ? "sm" : undefined}>{label}</Text>
153
157
  {!!description && <Text color="muted">{description}</Text>}
154
158
  </View>
155
159
  </PressableHighlight>
156
160
  );
157
161
  }
162
+
163
+ const styles = StyleSheet.create({
164
+ text: { flex: 1, minWidth: 0 },
165
+ textCompact: { flexShrink: 1, minWidth: 0 },
166
+ });
@@ -37,11 +37,11 @@ export interface SectionProps {
37
37
  * </Section>
38
38
  */
39
39
  export function Section(props: SectionProps) {
40
- // gap 12 spaces the heading from its body. Space BETWEEN sections belongs to
40
+ // gap 16 spaces the heading from its body. Space BETWEEN sections belongs to
41
41
  // the page column (`SectionStack`, 56 + hairline); a body of `Subsection`
42
- // groups divides via a nested `SubsectionStack` (24 + hairline) —
42
+ // groups divides via a nested `SubsectionStack` (32, space-only) —
43
43
  // never a hairline directly under the heading (it belongs to its content).
44
- return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
44
+ return <View style={[{ gap: 16 }, props.style]}>{props.children}</View>;
45
45
  }
46
46
 
47
47
  export interface SubsectionProps {
@@ -49,10 +49,12 @@ export function SectionStack(props: SectionStackProps) {
49
49
  export type SubsectionStackProps = StackProps;
50
50
 
51
51
  /**
52
- * The divided stack of `Subsection` groups INSIDE a `Section` — the same
53
- * hairline law one step tighter: a fixed 24px beat. Compose it as the
52
+ * The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat,
53
+ * SPACE-ONLY: subsection titles carry the grouping; hairlines belong to the
54
+ * SECTION level (`SectionStack`), one rule per altitude. Compose it as the
54
55
  * section's body (after the `SectionHeading`); a section's groups divide
55
- * without hand-rolled gaps or dividers.
56
+ * without hand-rolled gaps. `divided` stays an explicit opt-in for a rare
57
+ * headingless stack that still needs a rule.
56
58
  *
57
59
  * <Section>
58
60
  * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
@@ -63,5 +65,5 @@ export type SubsectionStackProps = StackProps;
63
65
  * </Section>
64
66
  */
65
67
  export function SubsectionStack(props: SubsectionStackProps) {
66
- return <DividedStack {...props} gap={24} />;
68
+ return <DividedStack {...props} divided={props.divided ?? false} gap={32} />;
67
69
  }
@@ -181,6 +181,12 @@ const styles = StyleSheet.create({
181
181
  borderRadius: CONTROL_RADIUS,
182
182
  borderWidth: 1,
183
183
  borderColor: colors.border,
184
+ // The input surface is WHITE on ANY background — an input is an open well
185
+ // to type into, and on a tinted panel (an inset fill editor, a toned card)
186
+ // a transparent input inherits the tint and blends into its surface. Never
187
+ // rely on the canvas showing through; a consumer wanting another surface
188
+ // overrides via `style` (it lands after this in the array).
189
+ backgroundColor: colors.white,
184
190
  height: 40,
185
191
  paddingVertical: 8,
186
192
  paddingHorizontal: 8,