@lotics/ui 22.3.0 → 23.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 +9 -2
- package/MIGRATION.md +142 -2
- package/docs/ai_patterns.md +22 -22
- package/docs/catalog.md +119 -98
- 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 +3 -2
- 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_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 +298 -59
- 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 +12 -4
- package/src/inline_time_picker.tsx +10 -4
- 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 +19 -1
- package/src/text_utils.ts +1 -1
- package/src/linked_record_box.tsx +0 -157
|
@@ -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,14 @@ 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
20
|
accessibilityLabel?: string;
|
|
21
|
+
/** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
|
|
22
|
+
* quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
|
|
23
|
+
* field never changes width on focus, and passed UNCONDITIONALLY (`disabled` the
|
|
24
|
+
* verb when there is nothing to act on). */
|
|
25
|
+
actions?: ReactNode;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -27,7 +32,7 @@ export interface InlineTextInputProps {
|
|
|
27
32
|
* value in a dense record / detail surface.
|
|
28
33
|
*/
|
|
29
34
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
30
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant } = props;
|
|
35
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions } = props;
|
|
31
36
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
32
37
|
|
|
33
38
|
const onKeyPress = useCallback(
|
|
@@ -49,7 +54,6 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
49
54
|
|
|
50
55
|
return (
|
|
51
56
|
<InlineEditFrame
|
|
52
|
-
variant={variant}
|
|
53
57
|
editing={edit.editing}
|
|
54
58
|
display={value}
|
|
55
59
|
placeholder={placeholder}
|
|
@@ -61,7 +65,9 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
61
65
|
error={edit.error}
|
|
62
66
|
disabled={disabled}
|
|
63
67
|
struck={struck}
|
|
68
|
+
variant={variant}
|
|
64
69
|
accessibilityLabel={accessibilityLabel}
|
|
70
|
+
actions={actions}
|
|
65
71
|
>
|
|
66
72
|
<TextInputField
|
|
67
73
|
value={edit.draft}
|
|
@@ -71,6 +77,8 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
71
77
|
autoFocus
|
|
72
78
|
placeholder={placeholder}
|
|
73
79
|
accessibilityLabel={accessibilityLabel}
|
|
80
|
+
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
81
|
+
seamless={actions != null}
|
|
74
82
|
/>
|
|
75
83
|
</InlineEditFrame>
|
|
76
84
|
);
|
|
@@ -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}
|
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
|
+
});
|
package/src/share_or_download.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type ShareOrDownloadResult =
|
|
|
30
30
|
| { delivered: "share"; count: number }
|
|
31
31
|
| { delivered: "download"; count: number };
|
|
32
32
|
|
|
33
|
-
/** "shared" = the sheet completed
|
|
33
|
+
/** "shared" = the sheet completed, "cancelled" = the user dismissed it,
|
|
34
34
|
* "unsupported" = Web Share (files) unavailable. A genuine share failure THROWS. */
|
|
35
35
|
export type ShareFilesResult = "shared" | "cancelled" | "unsupported";
|
|
36
36
|
|
|
@@ -65,7 +65,7 @@ export async function prepareShareFiles(
|
|
|
65
65
|
/**
|
|
66
66
|
* Share already-fetched File objects via the OS sheet. Call SYNCHRONOUSLY from a
|
|
67
67
|
* user gesture (no awaited fetch before it) so iOS keeps the transient activation.
|
|
68
|
-
* "cancelled" = user dismissed
|
|
68
|
+
* "cancelled" = user dismissed, "unsupported" = no Web Share; a real failure THROWS.
|
|
69
69
|
*/
|
|
70
70
|
export async function shareFiles(fileObjs: File[], opts?: { title?: string; text?: string }): Promise<ShareFilesResult> {
|
|
71
71
|
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
package/src/step_progress.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { Text } from "./text";
|
|
|
4
4
|
import { useTooltip } from "./tooltip";
|
|
5
5
|
|
|
6
6
|
export interface StepProgressProps {
|
|
7
|
-
/** The stages: pass the NAMES (enables the built-in "4/7
|
|
7
|
+
/** The stages: pass the NAMES (enables the built-in "In (4/7)" caption
|
|
8
8
|
* and per-segment hover names) or a bare count (bar only). */
|
|
9
9
|
steps: number | string[];
|
|
10
10
|
/** 0-based index of the stage in progress; earlier segments render
|
|
@@ -17,7 +17,7 @@ export interface StepProgressProps {
|
|
|
17
17
|
* uppercase eyebrow row above the bar, caption right. Omit at card
|
|
18
18
|
* density where the context already names it. */
|
|
19
19
|
title?: string;
|
|
20
|
-
/** Caption override for when the current stage needs prose ("In production") instead of the derived "3/6
|
|
20
|
+
/** Caption override for when the current stage needs prose ("In production") instead of the derived "SX (3/6)". */
|
|
21
21
|
label?: string;
|
|
22
22
|
/** Tone for the caption — `danger` flags a stalled/overdue stage (a stuck
|
|
23
23
|
* production order) in the FIXED caption spot, so the bar stays consistent
|
|
@@ -35,7 +35,7 @@ export interface StepProgressProps {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* THE compact stage indicator — N equal segments filled through the current
|
|
38
|
-
* stage, with a built-in "4/7
|
|
38
|
+
* stage, with a built-in "In (4/7)" caption when stages are named (hovering
|
|
39
39
|
* a segment names it). For *countable* stages an item walks through (a
|
|
40
40
|
* production line, a checklist, a pipeline) shown at card/list density.
|
|
41
41
|
*
|
|
@@ -55,8 +55,10 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
55
55
|
label ??
|
|
56
56
|
(names
|
|
57
57
|
? isComplete
|
|
58
|
-
?
|
|
59
|
-
:
|
|
58
|
+
? `Complete (${count}/${count})`
|
|
59
|
+
: safe >= 0
|
|
60
|
+
? `${names[safe]} (${safe + 1}/${count})`
|
|
61
|
+
: `0/${count}`
|
|
60
62
|
: undefined);
|
|
61
63
|
|
|
62
64
|
// `progressbar` IS right here — the segments are decoration over one quantity,
|
package/src/stepper.tsx
CHANGED
|
@@ -68,7 +68,7 @@ export interface StepProps extends StepPositional {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Progress through an ordered sequence — done
|
|
71
|
+
* Progress through an ordered sequence — done, current, upcoming on a
|
|
72
72
|
* connecting track (horizontal) or spine (vertical). The node encodes STATUS,
|
|
73
73
|
* not identity: a filled dot once reached, a ring with a white centre at the
|
|
74
74
|
* current step (pulsing when `live`), a faint ring for what's ahead, a check for
|
package/src/task.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
|
|
|
14
14
|
* <TaskList>
|
|
15
15
|
* <TaskItem>
|
|
16
16
|
* <TaskStatus><CheckCircle …/></TaskStatus>
|
|
17
|
-
* <TaskTitle><InlineTextInput variant="
|
|
17
|
+
* <TaskTitle><InlineTextInput variant="bare" struck={done} …/></TaskTitle>
|
|
18
18
|
* <TaskActions><ActionMenu …/></TaskActions>
|
|
19
19
|
* <TaskCaption>Waiting on the signed copy</TaskCaption>
|
|
20
20
|
* <TaskSubRow label="Portal account"><InlineTextInput …/></TaskSubRow>
|
|
@@ -81,7 +81,7 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
|
|
|
81
81
|
* **A value and a sentence are different things.** A `TaskSubRow` holds a NAMED value the reader
|
|
82
82
|
* sets; `TaskCaption` holds a sentence ABOUT the row ("Missing 2 of 6") on its own line under the
|
|
83
83
|
* title. Prose given a label reads as a field nobody can edit. Which is why a sub-row annotates
|
|
84
|
-
* like a record's field does — `description
|
|
84
|
+
* like a record's field does — `description`, `warning`, `error`, the one anatomy in
|
|
85
85
|
* `field_annotations` — so a fault in ONE field says so on that field instead of being demoted
|
|
86
86
|
* into a sentence about the whole row.
|
|
87
87
|
*
|
|
@@ -131,11 +131,11 @@ const TaskListContext = createContext<TaskListContextValue>({
|
|
|
131
131
|
*/
|
|
132
132
|
const SUB_VALUE_MIN = 200;
|
|
133
133
|
/**
|
|
134
|
-
* How far
|
|
134
|
+
* How far an inline control insets its own text — the kit's `CONTROL_TEXT_INSET` under
|
|
135
135
|
* this family's name, because a task surface reaches for it constantly. Everything that hangs
|
|
136
136
|
* beneath a row — the caption, a sub-row's label, the detail block — adds the same, so it lines
|
|
137
137
|
* up with the WORDS rather than with the editor's invisible box. A STRING title gets it from
|
|
138
|
-
* `TaskTitle`; only a custom title NODE that is not
|
|
138
|
+
* `TaskTitle`; only a custom title NODE that is not an inline control has to add it, or
|
|
139
139
|
* it sits out of line with the editable titles around it.
|
|
140
140
|
*/
|
|
141
141
|
export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
|
|
@@ -282,7 +282,7 @@ type TaskTitleProps =
|
|
|
282
282
|
| { children: ReactNode; struck?: never };
|
|
283
283
|
|
|
284
284
|
/**
|
|
285
|
-
* The task's identity — an inline editor
|
|
285
|
+
* The task's identity — an inline editor, or the title text itself.
|
|
286
286
|
*
|
|
287
287
|
* Pass a STRING and the title renders on the cell inset with the band's slack above and below
|
|
288
288
|
* it, so it lines up with the editable titles around it AND a title that wraps keeps its first
|
|
@@ -379,7 +379,7 @@ interface TaskSubRowProps extends FieldAnnotationProps {
|
|
|
379
379
|
* own line under the label rather than compressing to a few characters, and on a wide one it
|
|
380
380
|
* FILLS the content box, ending on the same x as the title above it and the ⋯ gutter beside it.
|
|
381
381
|
*
|
|
382
|
-
* It annotates exactly like a `DetailRow` — `description
|
|
382
|
+
* It annotates exactly like a `DetailRow` — `description`, `warning`, `error`, one shared
|
|
383
383
|
* anatomy (`field_annotations`). A field-level fault says so ON THE FIELD; demoting it into a
|
|
384
384
|
* `TaskCaption`, which is a sentence about the ROW, says something different.
|
|
385
385
|
*
|