@lotics/ui 22.3.0 → 23.1.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 +9 -2
- package/MIGRATION.md +142 -2
- package/docs/ai_patterns.md +22 -22
- package/docs/catalog.md +131 -99
- package/docs/composition.md +134 -42
- package/docs/data_entry.md +67 -31
- package/docs/templates.md +86 -68
- package/examples/tpl_allocate.tsx +5 -5
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +6 -6
- package/examples/tpl_dashboard.tsx +9 -9
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_item_list.tsx +64 -37
- package/examples/tpl_lookup.tsx +5 -5
- package/examples/tpl_pick.tsx +6 -6
- package/examples/tpl_pivot.tsx +3 -3
- package/examples/tpl_record.tsx +919 -611
- package/examples/tpl_report.tsx +2 -2
- package/examples/tpl_rollup.tsx +5 -5
- package/examples/tpl_shifts.tsx +5 -5
- package/examples/tpl_statements.tsx +7 -7
- package/examples/tpl_stock.tsx +2 -2
- package/examples/tpl_task_board.tsx +42 -26
- package/examples/tpl_tower.tsx +6 -6
- package/package.json +4 -3
- package/src/agent_run.tsx +4 -4
- package/src/breakdown.tsx +1 -1
- package/src/calendar/calendar_view.tsx +1 -1
- package/src/change_review.tsx +9 -8
- package/src/chip_group.tsx +12 -2
- package/src/choice_list.tsx +2 -2
- package/src/confidence.tsx +2 -2
- package/src/data_grid.tsx +1 -1
- package/src/detail_row.tsx +50 -58
- package/src/file_dropzone.tsx +1 -1
- package/src/file_gallery_modal.tsx +3 -3
- package/src/file_row.tsx +29 -4
- package/src/file_rows.tsx +1 -1
- package/src/finding.tsx +4 -4
- package/src/form_field.tsx +1 -1
- package/src/format_date.ts +2 -2
- package/src/heatmap.tsx +1 -1
- package/src/inline_button.tsx +84 -0
- package/src/inline_date_picker.tsx +17 -10
- package/src/inline_edit.tsx +341 -60
- package/src/inline_member_select.tsx +8 -3
- package/src/inline_number_input.tsx +11 -4
- package/src/inline_select.tsx +26 -13
- package/src/inline_text_input.tsx +27 -6
- package/src/inline_time_picker.tsx +10 -4
- package/src/input_metrics.ts +17 -0
- package/src/ledger.tsx +2 -2
- package/src/locale.tsx +7 -3
- package/src/matrix.tsx +1 -1
- package/src/number_input.tsx +18 -7
- package/src/pipeline.tsx +1 -1
- package/src/popover.tsx +1 -1
- package/src/press_door.tsx +1 -1
- package/src/pressable_highlight.tsx +1 -1
- package/src/progress_bar.tsx +3 -3
- package/src/record_summary.tsx +2 -2
- package/src/result_header.tsx +2 -2
- package/src/sequence.tsx +170 -0
- package/src/share_or_download.ts +2 -2
- package/src/step_progress.tsx +7 -5
- package/src/stepper.tsx +1 -1
- package/src/task.tsx +6 -6
- package/src/text_input_field.tsx +21 -4
- package/src/text_utils.ts +7 -1
- package/src/linked_record_box.tsx +0 -157
package/src/inline_select.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, type ReactNode } from "react";
|
|
1
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { Icon } from "./icon";
|
|
4
4
|
import { Text } from "./text";
|
|
@@ -24,9 +24,14 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
|
|
|
24
24
|
* as-is (e.g. an avatar "add" ghost for an `avatarOnly` member cell). */
|
|
25
25
|
placeholder?: string | ReactNode;
|
|
26
26
|
disabled?: boolean;
|
|
27
|
-
|
|
28
|
-
/** Form field (default) or grid cell — see {@link InlineEditVariant}. */
|
|
27
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
29
28
|
variant?: InlineEditVariant;
|
|
29
|
+
/** Verbs on the field's surface — an `InlineButton` Copy, Open… See
|
|
30
|
+
* `InlineEditView.actions`: they sit INSIDE the field, in the same place in
|
|
31
|
+
* every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
|
|
32
|
+
* has nothing to act on) or the field resizes as the value fills. */
|
|
33
|
+
actions?: ReactNode;
|
|
34
|
+
accessibilityLabel?: string;
|
|
30
35
|
/** Show an in-menu search box; default false. */
|
|
31
36
|
searchable?: boolean;
|
|
32
37
|
/** Offer a "create" row when the query matches no option — picking it commits the
|
|
@@ -67,30 +72,36 @@ function InlineSelectShell(props: {
|
|
|
67
72
|
display: string | ReactNode;
|
|
68
73
|
placeholder?: string | ReactNode;
|
|
69
74
|
accessibilityLabel?: string;
|
|
70
|
-
variant?: InlineEditVariant;
|
|
71
75
|
saving: boolean;
|
|
72
76
|
error: string | null;
|
|
73
77
|
children: ReactNode;
|
|
78
|
+
variant?: InlineEditVariant;
|
|
79
|
+
actions?: ReactNode;
|
|
74
80
|
}) {
|
|
81
|
+
// The popover INHERITS THE TRIGGER'S WIDTH, so it has to measure the FIELD —
|
|
82
|
+
// with verbs on the surface the value region inside it is narrower, and
|
|
83
|
+
// anchoring there would open a list too small for the row it belongs to.
|
|
84
|
+
const anchorRef = useRef<View>(null);
|
|
75
85
|
return (
|
|
76
86
|
<View>
|
|
77
|
-
<Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} side="bottom" align="start" inheritTriggerWidth>
|
|
87
|
+
<Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} triggerRef={anchorRef} side="bottom" align="start" inheritTriggerWidth>
|
|
78
88
|
<PopoverTrigger>
|
|
79
89
|
<InlineEditView
|
|
90
|
+
anchorRef={anchorRef}
|
|
91
|
+
actions={props.actions}
|
|
80
92
|
variant={props.variant}
|
|
81
93
|
display={props.display}
|
|
82
94
|
placeholder={props.placeholder}
|
|
83
95
|
disabled={props.disabled}
|
|
84
96
|
active={props.open && !props.disabled}
|
|
85
97
|
accessibilityLabel={props.accessibilityLabel}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// saving spinner shows in both.
|
|
98
|
+
// The resting chevron says "this opens a list" — the same promise on
|
|
99
|
+
// every surface, now that a field looks the same everywhere. The
|
|
100
|
+
// saving spinner replaces it while a write is in flight.
|
|
90
101
|
trailing={
|
|
91
102
|
props.saving ? (
|
|
92
103
|
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
93
|
-
) :
|
|
104
|
+
) : (
|
|
94
105
|
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
95
106
|
)
|
|
96
107
|
}
|
|
@@ -108,7 +119,7 @@ function InlineSelectShell(props: {
|
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
|
|
111
|
-
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel,
|
|
122
|
+
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions } = props;
|
|
112
123
|
const labels = useLoticsLocale().inline;
|
|
113
124
|
const [open, setOpen] = useState(false);
|
|
114
125
|
const [saving, setSaving] = useState(false);
|
|
@@ -151,13 +162,14 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
151
162
|
};
|
|
152
163
|
return (
|
|
153
164
|
<InlineSelectShell
|
|
165
|
+
variant={variant}
|
|
166
|
+
actions={actions}
|
|
154
167
|
open={open}
|
|
155
168
|
onOpenChange={onOpenChange}
|
|
156
169
|
disabled={disabled}
|
|
157
170
|
display={selected.length > 0 ? <View style={styles.tags}>{selected.map((o) => <View key={o.value}>{chip(o)}</View>)}</View> : ""}
|
|
158
171
|
placeholder={placeholder}
|
|
159
172
|
accessibilityLabel={accessibilityLabel}
|
|
160
|
-
variant={variant}
|
|
161
173
|
saving={saving}
|
|
162
174
|
error={error}
|
|
163
175
|
>
|
|
@@ -196,13 +208,14 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
196
208
|
};
|
|
197
209
|
return (
|
|
198
210
|
<InlineSelectShell
|
|
211
|
+
variant={variant}
|
|
212
|
+
actions={actions}
|
|
199
213
|
open={open}
|
|
200
214
|
onOpenChange={setOpen}
|
|
201
215
|
disabled={disabled}
|
|
202
216
|
display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
|
|
203
217
|
placeholder={placeholder}
|
|
204
218
|
accessibilityLabel={accessibilityLabel}
|
|
205
|
-
variant={variant}
|
|
206
219
|
saving={saving}
|
|
207
220
|
error={error}
|
|
208
221
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, type ReactNode } from "react";
|
|
2
2
|
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
|
3
3
|
import { TextInputField } from "./text_input_field";
|
|
4
4
|
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
@@ -15,9 +15,22 @@ export interface InlineTextInputProps {
|
|
|
15
15
|
disabled?: boolean;
|
|
16
16
|
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
17
17
|
struck?: boolean;
|
|
18
|
-
/**
|
|
18
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
19
19
|
variant?: InlineEditVariant;
|
|
20
|
+
/**
|
|
21
|
+
* Line budget for a value a reader has to read WHOLE rather than recognise —
|
|
22
|
+
* a payment term, an address, a clause. Default 1 (the single-line field).
|
|
23
|
+
* Above 1 the resting value wraps to that many lines AND the editor opens
|
|
24
|
+
* multiline at the same height, so the row does not reflow on click. Enter
|
|
25
|
+
* then inserts a newline; the field commits on blur (or the ✓ in "buttons").
|
|
26
|
+
*/
|
|
27
|
+
numberOfLines?: number;
|
|
20
28
|
accessibilityLabel?: string;
|
|
29
|
+
/** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
|
|
30
|
+
* quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
|
|
31
|
+
* field never changes width on focus, and passed UNCONDITIONALLY (`disabled` the
|
|
32
|
+
* verb when there is nothing to act on). */
|
|
33
|
+
actions?: ReactNode;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
@@ -27,16 +40,18 @@ export interface InlineTextInputProps {
|
|
|
27
40
|
* value in a dense record / detail surface.
|
|
28
41
|
*/
|
|
29
42
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
30
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant } = props;
|
|
43
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
|
|
44
|
+
const multiline = (numberOfLines ?? 1) > 1;
|
|
31
45
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
32
46
|
|
|
33
47
|
const onKeyPress = useCallback(
|
|
34
48
|
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
35
49
|
const key = e.nativeEvent.key;
|
|
36
50
|
if (key === "Escape") edit.cancel();
|
|
37
|
-
|
|
51
|
+
// On a multiline field Enter is a NEWLINE; the value commits on blur.
|
|
52
|
+
else if (key === "Enter" && !multiline) void edit.commit();
|
|
38
53
|
},
|
|
39
|
-
[edit],
|
|
54
|
+
[edit, multiline],
|
|
40
55
|
);
|
|
41
56
|
|
|
42
57
|
// Commit on blur (the ergonomic default for moving through a form). In
|
|
@@ -49,7 +64,6 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
49
64
|
|
|
50
65
|
return (
|
|
51
66
|
<InlineEditFrame
|
|
52
|
-
variant={variant}
|
|
53
67
|
editing={edit.editing}
|
|
54
68
|
display={value}
|
|
55
69
|
placeholder={placeholder}
|
|
@@ -61,7 +75,10 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
61
75
|
error={edit.error}
|
|
62
76
|
disabled={disabled}
|
|
63
77
|
struck={struck}
|
|
78
|
+
variant={variant}
|
|
79
|
+
numberOfLines={numberOfLines}
|
|
64
80
|
accessibilityLabel={accessibilityLabel}
|
|
81
|
+
actions={actions}
|
|
65
82
|
>
|
|
66
83
|
<TextInputField
|
|
67
84
|
value={edit.draft}
|
|
@@ -69,8 +86,12 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
69
86
|
onBlur={onBlur}
|
|
70
87
|
onKeyPress={onKeyPress}
|
|
71
88
|
autoFocus
|
|
89
|
+
multiline={multiline}
|
|
90
|
+
numberOfLines={numberOfLines}
|
|
72
91
|
placeholder={placeholder}
|
|
73
92
|
accessibilityLabel={accessibilityLabel}
|
|
93
|
+
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
94
|
+
seamless={actions != null}
|
|
74
95
|
/>
|
|
75
96
|
</InlineEditFrame>
|
|
76
97
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
1
|
+
import { useCallback, useRef, type ReactNode } from "react";
|
|
2
2
|
import { Icon } from "./icon";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { TimePicker } from "./time_picker";
|
|
@@ -14,9 +14,14 @@ export interface InlineTimePickerProps {
|
|
|
14
14
|
* an explicit ✓ saves and ✕ reverts. */
|
|
15
15
|
controls?: InlineEditControls;
|
|
16
16
|
disabled?: boolean;
|
|
17
|
-
|
|
18
|
-
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
17
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
19
18
|
variant?: InlineEditVariant;
|
|
19
|
+
/** Verbs on the field's surface — an `InlineButton` Copy, Open… See
|
|
20
|
+
* `InlineEditView.actions`: they sit INSIDE the field, in the same place in
|
|
21
|
+
* every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
|
|
22
|
+
* has nothing to act on) or the field resizes as the value fills. */
|
|
23
|
+
actions?: ReactNode;
|
|
24
|
+
accessibilityLabel?: string;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -25,7 +30,7 @@ export interface InlineTimePickerProps {
|
|
|
25
30
|
* field at the same height, so the form never reflows.
|
|
26
31
|
*/
|
|
27
32
|
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
28
|
-
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel
|
|
33
|
+
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
|
|
29
34
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
30
35
|
const dateLabels = useLoticsLocale().datePicker;
|
|
31
36
|
// Segments emit ONLY on a complete "HH:mm", so a half-typed entry (an hour and
|
|
@@ -42,6 +47,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
42
47
|
return (
|
|
43
48
|
<InlineEditFrame
|
|
44
49
|
variant={variant}
|
|
50
|
+
actions={actions}
|
|
45
51
|
editing={edit.editing}
|
|
46
52
|
display={value}
|
|
47
53
|
placeholder={placeholder}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Vertical breathing room inside an input, per side. */
|
|
2
|
+
export const INPUT_VERTICAL_PADDING = 8;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The height a MULTI-LINE input occupies, for `numberOfLines` lines of text.
|
|
6
|
+
*
|
|
7
|
+
* One formula with two callers on purpose: `TextInputField` sizes the editor
|
|
8
|
+
* with it, and an inline field's RESTING box reserves the same number. When
|
|
9
|
+
* each computed its own, the resting box came out 22px shorter and every click
|
|
10
|
+
* on a payment-term field shoved the rest of the form down the page.
|
|
11
|
+
*
|
|
12
|
+
* RN-free so it can be tested directly — the caller supplies the line height,
|
|
13
|
+
* which is the only platform-dependent part.
|
|
14
|
+
*/
|
|
15
|
+
export function multilineInputHeight(numberOfLines: number, lineHeight: number): number {
|
|
16
|
+
return numberOfLines * lineHeight + INPUT_VERTICAL_PADDING * 2;
|
|
17
|
+
}
|
package/src/ledger.tsx
CHANGED
|
@@ -23,7 +23,7 @@ import { Text } from "./text";
|
|
|
23
23
|
// <LedgerRow label="Levy" value={levy} reference={{ label: "INV-0414", onPress }} />
|
|
24
24
|
// </LedgerGroup>
|
|
25
25
|
// <LedgerGroup label="Received" total={-received}>
|
|
26
|
-
// <LedgerRow label="Deposit" meta="03/06
|
|
26
|
+
// <LedgerRow label="Deposit" meta="03/06, Cash" value={-1_550_000} tone="success" />
|
|
27
27
|
// </LedgerGroup>
|
|
28
28
|
// <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
|
|
29
29
|
// </Ledger>
|
|
@@ -95,7 +95,7 @@ export function LedgerGroup(props: LedgerGroupProps) {
|
|
|
95
95
|
export interface LedgerRowProps {
|
|
96
96
|
/** The line's name — what was charged / received. */
|
|
97
97
|
label: string;
|
|
98
|
-
/** Inline context after the label — a date
|
|
98
|
+
/** Inline context after the label — a date, method, a basis, a period. */
|
|
99
99
|
meta?: string;
|
|
100
100
|
/** The amount. Negative renders "− <abs>" (a receipt). */
|
|
101
101
|
value: number;
|
package/src/locale.tsx
CHANGED
|
@@ -65,6 +65,8 @@ export interface LoticsLocale {
|
|
|
65
65
|
sectionHeading: { info: string };
|
|
66
66
|
/** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
|
|
67
67
|
chip: { remove: string };
|
|
68
|
+
/** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
|
|
69
|
+
sequence: { moveUp: string; moveDown: string; remove: string };
|
|
68
70
|
/** `TrendFooter`: the direction words of the trend sentence. */
|
|
69
71
|
trendFooter: { up: string; down: string };
|
|
70
72
|
/** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
|
|
@@ -114,12 +116,12 @@ export interface LoticsLocale {
|
|
|
114
116
|
overlay: { close: string };
|
|
115
117
|
/** `FileDropzone`: the resting main line, the secondary hint, and the
|
|
116
118
|
* drag-hover line. `hint` is a generic default — apps override the prop with
|
|
117
|
-
* an accepted-types line ("
|
|
119
|
+
* an accepted-types line ("…, PDF, ảnh"). */
|
|
118
120
|
fileDropzone: { label: string; hint: string; drop: string };
|
|
119
121
|
/** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
|
|
120
122
|
fileThumbnail: { remove: string };
|
|
121
|
-
/** `FilesEditor`'s shipped bar pieces (upload
|
|
122
|
-
*
|
|
123
|
+
/** `FilesEditor`'s shipped bar pieces (upload, select, select-all, download,
|
|
124
|
+
* remove) and the remove confirm. A HOST verb is a plain `Button` and names
|
|
123
125
|
* itself, so nothing here is about what an app happens to do with a file. */
|
|
124
126
|
filesEditor: {
|
|
125
127
|
upload: string;
|
|
@@ -222,6 +224,7 @@ export const en: LoticsLocale = {
|
|
|
222
224
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
223
225
|
sectionHeading: { info: "About this data" },
|
|
224
226
|
chip: { remove: "Remove" },
|
|
227
|
+
sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
|
|
225
228
|
trendFooter: { up: "Up", down: "Down" },
|
|
226
229
|
suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
|
|
227
230
|
confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
|
|
@@ -373,6 +376,7 @@ export const vi: LoticsLocale = {
|
|
|
373
376
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
374
377
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
375
378
|
chip: { remove: "Xóa" },
|
|
379
|
+
sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
|
|
376
380
|
trendFooter: { up: "Tăng", down: "Giảm" },
|
|
377
381
|
suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
|
|
378
382
|
confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
|
package/src/matrix.tsx
CHANGED
|
@@ -203,7 +203,7 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
|
|
|
203
203
|
|
|
204
204
|
const isSelected = selected?.row === row.key && selected?.col === col.key;
|
|
205
205
|
const pressable = !!onSelectCell && value > 0;
|
|
206
|
-
const label = `${row.label}
|
|
206
|
+
const label = `${row.label}, ${col.label}: ${formatValue(value)}`;
|
|
207
207
|
|
|
208
208
|
const content = showNumber ? (
|
|
209
209
|
<Text size="sm" weight="medium" tabular align="center" color={value === 0 ? "zinc-500" : "default"}>
|
package/src/number_input.tsx
CHANGED
|
@@ -18,6 +18,17 @@ export interface NumberInputProps {
|
|
|
18
18
|
disabled?: boolean;
|
|
19
19
|
testID?: string;
|
|
20
20
|
accessibilityLabel?: string;
|
|
21
|
+
/** The HOST paints the surface — drop this input's own border, fill, focus ring
|
|
22
|
+
* AND horizontal padding, so the page draws one box and the text lands on the
|
|
23
|
+
* host's inset rather than 8px further in. Set by an inline editor whose field
|
|
24
|
+
* carries `actions`: the field's shell owns the surface (so the verbs never
|
|
25
|
+
* move between modes) and owns the ring with it.
|
|
26
|
+
*
|
|
27
|
+
* HEIGHT and VERTICAL padding stay — those are what keep the field the same
|
|
28
|
+
* size in both modes; the horizontal inset is what has to be surrendered,
|
|
29
|
+
* because the host already spent `CONTROL_TEXT_INSET` on it and two insets
|
|
30
|
+
* stacked is a visible jump to the right on focus. */
|
|
31
|
+
seamless?: boolean;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/**
|
|
@@ -26,7 +37,7 @@ export interface NumberInputProps {
|
|
|
26
37
|
* number in place on a record use `InlineNumberInput`.
|
|
27
38
|
*/
|
|
28
39
|
export function NumberInput(props: NumberInputProps) {
|
|
29
|
-
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
|
|
40
|
+
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, seamless } = props;
|
|
30
41
|
const binding = useFormField();
|
|
31
42
|
const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
32
43
|
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
@@ -55,17 +66,17 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
55
66
|
autoFocus={autoFocus}
|
|
56
67
|
style={{
|
|
57
68
|
height: 40,
|
|
58
|
-
paddingLeft: 8,
|
|
59
|
-
paddingRight: 8,
|
|
69
|
+
paddingLeft: seamless ? 0 : 8,
|
|
70
|
+
paddingRight: seamless ? 0 : 8,
|
|
60
71
|
borderRadius: CONTROL_RADIUS,
|
|
61
|
-
borderWidth: 1,
|
|
72
|
+
borderWidth: seamless ? 0 : 1,
|
|
62
73
|
borderStyle: "solid",
|
|
63
|
-
borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
|
|
64
|
-
backgroundColor: colors.background,
|
|
74
|
+
borderColor: seamless ? "transparent" : hovered && !disabled ? HOVER_BORDER : colors.border,
|
|
75
|
+
backgroundColor: seamless ? "transparent" : colors.background,
|
|
65
76
|
fontFamily: fontFamilyRegular,
|
|
66
77
|
...inputTextStyleWeb,
|
|
67
78
|
letterSpacing: -0.4,
|
|
68
|
-
boxShadow: focusVisible ? FOCUS_RING : "none",
|
|
79
|
+
boxShadow: !seamless && focusVisible ? FOCUS_RING : "none",
|
|
69
80
|
outline: "none",
|
|
70
81
|
boxSizing: "border-box",
|
|
71
82
|
transition: "border-color 0.12s, box-shadow 0.12s",
|
package/src/pipeline.tsx
CHANGED
|
@@ -45,7 +45,7 @@ export interface PipelineProps {
|
|
|
45
45
|
* <PipelineStage status="done" title="Submitted">
|
|
46
46
|
* <PipelineField label="Date"><InlineDatePicker … /></PipelineField>
|
|
47
47
|
* </PipelineStage>
|
|
48
|
-
* <PipelineStage status="current" title="In review" meta="Waiting 3d
|
|
48
|
+
* <PipelineStage status="current" title="In review" meta="Waiting 3d, Ops">
|
|
49
49
|
* <PipelineNote tone="warning">Sent back — missing payslips.</PipelineNote>
|
|
50
50
|
* <PipelineActions>
|
|
51
51
|
* <Button title="Approve" color="primary" />
|
package/src/popover.tsx
CHANGED
|
@@ -656,7 +656,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
656
656
|
// the field.
|
|
657
657
|
//
|
|
658
658
|
// Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
|
|
659
|
-
//
|
|
659
|
+
// a select in a dense grid column — still opens a list
|
|
660
660
|
// wide enough to read, rather than inheriting a width nothing fits in.
|
|
661
661
|
...(inheritTriggerWidth &&
|
|
662
662
|
!small &&
|
package/src/press_door.tsx
CHANGED
|
@@ -42,7 +42,7 @@ export interface PressDoorProps {
|
|
|
42
42
|
* Wrap the content in the pressable itself (`PressableHighlight`, `FileRow`'s door) ONLY
|
|
43
43
|
* when that content is non-interactive by construction.
|
|
44
44
|
*
|
|
45
|
-
* `TableRow`
|
|
45
|
+
* `TableRow` is the in-kit consumer.
|
|
46
46
|
*/
|
|
47
47
|
export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
|
|
48
48
|
const { focusVisible, focusProps } = useFocusRing();
|
|
@@ -76,7 +76,7 @@ export interface PressableHighlightProps extends PressableProps {
|
|
|
76
76
|
* checkbox, a `Link`) must not be built on it: a button cannot contain interactive
|
|
77
77
|
* descendants. Compose the role-less `PressableRow` + a `PressDoor` sibling instead —
|
|
78
78
|
* the surface takes the mouse, the door takes the keyboard, the controls stay their own
|
|
79
|
-
* tab stops (`TableRow`
|
|
79
|
+
* tab stops (`TableRow` is the reference).
|
|
80
80
|
*/
|
|
81
81
|
export function PressableHighlight(props: PressableHighlightProps) {
|
|
82
82
|
const {
|
package/src/progress_bar.tsx
CHANGED
|
@@ -13,7 +13,7 @@ export interface ProgressBarProps {
|
|
|
13
13
|
* it. */
|
|
14
14
|
title?: string;
|
|
15
15
|
/** Caption above-right of the bar: `percentage` → "50%", `fraction` →
|
|
16
|
-
* "1,250 / 2,500
|
|
16
|
+
* "1,250 / 2,500 (50%)" (separators follow the reader's locale). Reports the
|
|
17
17
|
* TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
|
|
18
18
|
format?: ProgressBarFormat;
|
|
19
19
|
/**
|
|
@@ -58,7 +58,7 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
58
58
|
const localeTag = useLocaleTag();
|
|
59
59
|
/**
|
|
60
60
|
* The TRACK clamps — a fill wider than its own track is meaningless. The CAPTION does not: a
|
|
61
|
-
* meter reading `2.100 / 2.000
|
|
61
|
+
* meter reading `2.100 / 2.000 (100%)` asserts a number that is false, and a reader scanning
|
|
62
62
|
* percentages sees "exactly at the limit" when they are over it. Progress-toward-completion
|
|
63
63
|
* never exceeds its max, so this only diverges for the over-allowance case, where the true
|
|
64
64
|
* figure is the whole point.
|
|
@@ -87,7 +87,7 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
87
87
|
|
|
88
88
|
const caption =
|
|
89
89
|
format === "fraction"
|
|
90
|
-
? `${num(value)} / ${num(max)}
|
|
90
|
+
? `${num(value)} / ${num(max)} (${Math.round(ratio)}%)`
|
|
91
91
|
: format === "percentage"
|
|
92
92
|
? `${Math.round(ratio)}%`
|
|
93
93
|
: null;
|
package/src/record_summary.tsx
CHANGED
|
@@ -20,7 +20,7 @@ export interface RecordSummaryProps {
|
|
|
20
20
|
* the record page's one h1; pass 2 when the band sits inside a surface
|
|
21
21
|
* that already owns the h1 (a drawer with its own titled header). */
|
|
22
22
|
level?: 1 | 2 | 3;
|
|
23
|
-
/** Identity qualifiers on the title line ("HCM → Hamburg
|
|
23
|
+
/** Identity qualifiers on the title line ("HCM → Hamburg, Export, FCL") —
|
|
24
24
|
* sm muted; wraps under the title when narrow. */
|
|
25
25
|
subtitle?: string;
|
|
26
26
|
/** A status chip (a `Badge`) or any small node right after the title. */
|
|
@@ -32,7 +32,7 @@ export interface RecordSummaryProps {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* The identity band of a single record's detail screen or drawer: ONE row —
|
|
35
|
-
* title
|
|
35
|
+
* title, subtitle, status chip, with an optional headline metric pinned
|
|
36
36
|
* right. It exists to stop hand-rolled record headers drifting: mixed type
|
|
37
37
|
* scales, several competing figures, color noise.
|
|
38
38
|
*
|
package/src/result_header.tsx
CHANGED
|
@@ -10,8 +10,8 @@ import { Text, type HeadingLevel } from "./text";
|
|
|
10
10
|
* ALREADY CREATED. The receipt states the outcome and routes — it never edits
|
|
11
11
|
* (correction happens through the record's ordinary verbs, on the record):
|
|
12
12
|
*
|
|
13
|
-
* - ONE record → `ResultHeader` (tone mark inline on the title row
|
|
14
|
-
* title
|
|
13
|
+
* - ONE record → `ResultHeader` (tone mark inline on the title row, outcome
|
|
14
|
+
* title, failure reason line) + a handful of spread `DetailRow` receipt
|
|
15
15
|
* lines (the identifying figures, not the field wall) + a `Confidence`
|
|
16
16
|
* callout naming exactly which values failed their deterministic checks.
|
|
17
17
|
* - SEVERAL records → an attention-first `ListItem` register whose rows press
|
package/src/sequence.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Children, createContext, isValidElement, useContext, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { IconButton } from "./icon_button";
|
|
5
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
|
|
9
|
+
interface SequencePosition {
|
|
10
|
+
index: number;
|
|
11
|
+
count: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SequenceItemContext = createContext<SequencePosition | null>(null);
|
|
15
|
+
|
|
16
|
+
/** Half the control band, less the dot's own radius — puts the dot on the SAME
|
|
17
|
+
* line as the first control in the item, not at the item's vertical centre (an
|
|
18
|
+
* item that wraps to two lines must not drag its marker down). */
|
|
19
|
+
const RAIL_LEAD = Math.round(INLINE_CONTROL_HEIGHT / 2) - 4;
|
|
20
|
+
const RAIL_WIDTH = 12;
|
|
21
|
+
/** The rail column plus its gap — what content must be indented by to line up
|
|
22
|
+
* under the items (an "Add" link, a caption). Exported so a caller never
|
|
23
|
+
* hand-picks a number that drifts from the rail. */
|
|
24
|
+
export const SEQUENCE_INSET = RAIL_WIDTH + 10;
|
|
25
|
+
|
|
26
|
+
export interface SequenceProps {
|
|
27
|
+
/** `SequenceItem`s, in order. Order IS the data — render them from your list. */
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An ORDERED list whose order is part of the data — a route's stops, an
|
|
33
|
+
* approval chain, a set of legs — rendered as a connected rail so the sequence
|
|
34
|
+
* reads without a single label saying "first" or "then".
|
|
35
|
+
*
|
|
36
|
+
* Reach for it when a thing has a variable number of POSITIONS. The alternative
|
|
37
|
+
* — one field per position (`Origin`, `Transfer point`, `Destination`) — cannot
|
|
38
|
+
* hold a second middle entry and cannot say that the entries are ordered; three
|
|
39
|
+
* identical field rows say nothing about which comes first.
|
|
40
|
+
*
|
|
41
|
+
* It is NOT a `Timeline` (an activity FEED: who changed what, when), NOT a
|
|
42
|
+
* `Stepper` (fixed named positions of a wizard), and NOT a `Pipeline` (ONE
|
|
43
|
+
* record walking stages that each own their controls). This is a list the user
|
|
44
|
+
* EDITS: add, remove, reorder.
|
|
45
|
+
*
|
|
46
|
+
* Each item supplies its own content — the kit draws the rail, the position
|
|
47
|
+
* affordances and the spacing:
|
|
48
|
+
*
|
|
49
|
+
* <Sequence>
|
|
50
|
+
* {stops.map((s, i) => (
|
|
51
|
+
* <SequenceItem key={s.id} role={roleFor(i)} onMoveUp={…} onMoveDown={…} onRemove={…}>
|
|
52
|
+
* <InlineTextInput … />
|
|
53
|
+
* </SequenceItem>
|
|
54
|
+
* ))}
|
|
55
|
+
* </Sequence>
|
|
56
|
+
*/
|
|
57
|
+
export function Sequence({ children }: SequenceProps) {
|
|
58
|
+
const items = Children.toArray(children).filter(isValidElement);
|
|
59
|
+
return (
|
|
60
|
+
// NO gap: the rail is continuous, so each item's trailing segment has to
|
|
61
|
+
// meet the next item's leading one. Item spacing is the item's own padding,
|
|
62
|
+
// which the rail runs through.
|
|
63
|
+
<View>
|
|
64
|
+
{items.map((child, index) => (
|
|
65
|
+
<SequenceItemContext.Provider key={index} value={{ index, count: items.length }}>
|
|
66
|
+
{child}
|
|
67
|
+
</SequenceItemContext.Provider>
|
|
68
|
+
))}
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SequenceItemProps {
|
|
74
|
+
/** What this position IS, derived by the caller from its index (`Origin`,
|
|
75
|
+
* `Via`, `Destination`). Derive it, never store it — a stored role lies the
|
|
76
|
+
* moment the list is reordered. */
|
|
77
|
+
role?: string;
|
|
78
|
+
/** The item's editors / content. */
|
|
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. */
|
|
82
|
+
onMoveUp?: () => void;
|
|
83
|
+
onMoveDown?: () => void;
|
|
84
|
+
onRemove?: () => void;
|
|
85
|
+
/** Names the item in the reorder/remove controls' announcements — "Remove
|
|
86
|
+
* Antwerp" rather than three identical "Remove" buttons. */
|
|
87
|
+
accessibilityName?: string;
|
|
88
|
+
/** Width of the role column. Default 74. */
|
|
89
|
+
roleWidth?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One position in a {@link Sequence} — its marker, its role, its content, and
|
|
94
|
+
* the controls that move or drop it.
|
|
95
|
+
*
|
|
96
|
+
* The reorder affordance is a pair of BUTTONS, not a drag handle: dragging is
|
|
97
|
+
* invisible to the keyboard, unreachable on a screen reader, and fiddly on a
|
|
98
|
+
* phone, and a list of three-to-six positions does not need the expressiveness.
|
|
99
|
+
* They render even where they cannot act (first item, last item) so the row's
|
|
100
|
+
* right edge never shifts between items.
|
|
101
|
+
*/
|
|
102
|
+
export function SequenceItem(props: SequenceItemProps) {
|
|
103
|
+
const { role, children, onMoveUp, onMoveDown, onRemove, accessibilityName, roleWidth = 74 } = props;
|
|
104
|
+
const pos = useContext(SequenceItemContext);
|
|
105
|
+
const labels = useLoticsLocale().sequence;
|
|
106
|
+
const first = pos == null || pos.index === 0;
|
|
107
|
+
const last = pos == null || pos.index === pos.count - 1;
|
|
108
|
+
const named = (verb: string) => (accessibilityName ? `${verb} ${accessibilityName}` : verb);
|
|
109
|
+
return (
|
|
110
|
+
<View style={styles.item}>
|
|
111
|
+
{/* THE RAIL — a leading segment, the dot on the control line, then a
|
|
112
|
+
trailing segment that fills whatever height the content takes. The end
|
|
113
|
+
segments are transparent rather than absent so every item keeps the
|
|
114
|
+
same geometry and the dots stay on one x. */}
|
|
115
|
+
<View style={styles.rail}>
|
|
116
|
+
<View style={[styles.segment, { height: RAIL_LEAD }, first && styles.segmentHidden]} />
|
|
117
|
+
<View style={styles.dot} />
|
|
118
|
+
<View style={[styles.segment, styles.segmentFill, last && styles.segmentHidden]} />
|
|
119
|
+
</View>
|
|
120
|
+
<View style={styles.body}>
|
|
121
|
+
{role != null ? (
|
|
122
|
+
<View style={{ width: roleWidth }}>
|
|
123
|
+
<Text size="xs" color="muted">
|
|
124
|
+
{role}
|
|
125
|
+
</Text>
|
|
126
|
+
</View>
|
|
127
|
+
) : null}
|
|
128
|
+
<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>
|
|
152
|
+
</View>
|
|
153
|
+
</View>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const styles = StyleSheet.create({
|
|
158
|
+
item: { flexDirection: "row", alignItems: "stretch", gap: 10 },
|
|
159
|
+
rail: { width: RAIL_WIDTH, alignItems: "center" },
|
|
160
|
+
segment: { width: 1, backgroundColor: colors.zinc[300] },
|
|
161
|
+
// Fills the rest of the item, INCLUDING its bottom padding, so the line
|
|
162
|
+
// arrives at the next item's leading segment with nothing between them.
|
|
163
|
+
segmentFill: { flex: 1 },
|
|
164
|
+
segmentHidden: { backgroundColor: "transparent" },
|
|
165
|
+
dot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.zinc[400] },
|
|
166
|
+
// The item's own spacing — a gap on the parent would break the rail.
|
|
167
|
+
body: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4, paddingBottom: 6 },
|
|
168
|
+
content: { flexGrow: 1, flexBasis: 200, minWidth: 0, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 },
|
|
169
|
+
controls: { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
170
|
+
});
|