@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/AGENTS.md +13 -0
- package/docs/catalog.md +37 -19
- package/docs/composition.md +60 -3
- package/docs/data_entry.md +26 -7
- package/docs/templates.md +148 -50
- package/examples/tpl_documents.tsx +29 -18
- package/examples/tpl_item_list.tsx +27 -8
- package/examples/tpl_record.tsx +2214 -555
- package/package.json +1 -1
- package/src/back_button.tsx +26 -33
- package/src/checklist.tsx +30 -15
- package/src/comments_thread.tsx +3 -62
- package/src/detail_row.tsx +86 -40
- package/src/inline_edit.tsx +5 -0
- package/src/inline_select.tsx +5 -5
- package/src/radio_picker.tsx +11 -2
- package/src/section_heading.tsx +3 -3
- package/src/section_stack.tsx +6 -4
- package/src/text_input_field.tsx +6 -0
- package/src/timeline.tsx +9 -9
package/package.json
CHANGED
package/src/back_button.tsx
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { IconButton } from "./icon_button";
|
|
2
|
-
import {
|
|
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
|
|
11
|
-
* part of the SAME control — a label you can't press is a lie. Omit
|
|
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
|
|
23
|
+
// Bare — the circular glyph IS the control.
|
|
23
24
|
if (label === undefined) {
|
|
24
25
|
return (
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 —
|
|
38
|
-
//
|
|
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
|
-
<
|
|
41
|
+
<PressableHighlight
|
|
42
|
+
focusRing
|
|
41
43
|
accessibilityRole="button"
|
|
42
44
|
accessibilityLabel={accessibilityLabel}
|
|
45
|
+
userSelect="none"
|
|
43
46
|
onPress={onPress}
|
|
44
|
-
style={
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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,
|
|
7
|
-
// CONTENT stays composed:
|
|
8
|
-
//
|
|
9
|
-
// `
|
|
10
|
-
//
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
117
|
+
meta: { paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
|
|
118
|
+
expansion: { paddingTop: 2, paddingBottom: 6 },
|
|
104
119
|
});
|
package/src/comments_thread.tsx
CHANGED
|
@@ -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
|
|
277
|
-
*
|
|
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
|
-
}
|
package/src/detail_row.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
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",
|
|
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
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
{
|
|
171
|
-
|
|
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
|
-
<
|
|
174
|
-
{
|
|
175
|
-
|
|
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
|
-
//
|
|
189
|
-
table: { gap:
|
|
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: "
|
|
242
|
+
alignItems: "flex-start",
|
|
202
243
|
gap: 12,
|
|
203
244
|
},
|
|
204
245
|
flexLabel: { flex: 1 },
|
|
205
|
-
|
|
206
|
-
|
|
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
|
package/src/inline_edit.tsx
CHANGED
|
@@ -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,
|
package/src/inline_select.tsx
CHANGED
|
@@ -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);
|
package/src/radio_picker.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/section_heading.tsx
CHANGED
|
@@ -37,11 +37,11 @@ export interface SectionProps {
|
|
|
37
37
|
* </Section>
|
|
38
38
|
*/
|
|
39
39
|
export function Section(props: SectionProps) {
|
|
40
|
-
// gap
|
|
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` (
|
|
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:
|
|
44
|
+
return <View style={[{ gap: 16 }, props.style]}>{props.children}</View>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface SubsectionProps {
|
package/src/section_stack.tsx
CHANGED
|
@@ -49,10 +49,12 @@ export function SectionStack(props: SectionStackProps) {
|
|
|
49
49
|
export type SubsectionStackProps = StackProps;
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* The
|
|
53
|
-
*
|
|
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
|
|
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={
|
|
68
|
+
return <DividedStack {...props} divided={props.divided ?? false} gap={32} />;
|
|
67
69
|
}
|
package/src/text_input_field.tsx
CHANGED
|
@@ -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,
|