@lotics/ui 44.7.1 → 44.9.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 +15 -3
- package/MIGRATION.md +110 -5
- package/docs/ai_patterns.md +20 -0
- package/docs/catalog.md +100 -8
- package/docs/composition.md +100 -6
- package/docs/data_entry.md +60 -6
- package/docs/reviewing.md +44 -3
- package/examples/tpl_item_list.tsx +8 -4
- package/package.json +4 -1
- package/src/accordion.tsx +9 -1
- package/src/agent_run_pane.tsx +7 -5
- package/src/card.tsx +7 -1
- package/src/card_select_item.tsx +15 -2
- package/src/checklist.tsx +23 -3
- package/src/control_surface.ts +18 -4
- package/src/data_grid.tsx +9 -1
- package/src/date_range_filter_field.tsx +9 -2
- package/src/date_stamp.tsx +8 -2
- package/src/detail_row.tsx +17 -12
- package/src/empty_state.tsx +9 -2
- package/src/error_state.tsx +9 -2
- package/src/field_annotations.tsx +25 -2
- package/src/finding.tsx +19 -10
- package/src/format_money.ts +16 -2
- package/src/inline_edit.tsx +29 -9
- package/src/inline_select.tsx +8 -1
- package/src/inline_text_input.tsx +6 -7
- package/src/ledger.tsx +5 -2
- package/src/locale.tsx +35 -1
- package/src/number_input.tsx +8 -1
- package/src/reference_field.tsx +9 -2
- package/src/related_record_row.tsx +80 -0
- package/src/running_ledger.tsx +196 -0
- package/src/section_heading.tsx +17 -5
- package/src/select.tsx +16 -16
- package/src/sequence.tsx +41 -25
- package/src/step_progress.tsx +6 -4
- package/src/stepper.tsx +26 -2
- package/src/table_fit.ts +18 -8
- package/src/tabs.tsx +19 -0
- package/src/text_input_field.tsx +35 -1
|
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
-
import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
5
|
+
import { CONTROL_HEIGHT, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
6
6
|
import { Icon } from "./icon";
|
|
7
7
|
import { Button } from "./button";
|
|
8
8
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
@@ -129,7 +129,14 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
129
129
|
flexDirection: "row",
|
|
130
130
|
alignItems: "center",
|
|
131
131
|
gap: 8,
|
|
132
|
-
|
|
132
|
+
// The height comes from the shared constant, never from padding
|
|
133
|
+
// tuned against a particular leading. This was `paddingVertical: 9`,
|
|
134
|
+
// which was exactly 40 against the OLD `sm` 14/20 — then 43.5.0 made
|
|
135
|
+
// running text 14/24 and the same padding silently became 44, one
|
|
136
|
+
// control standing 4px proud of every sibling in its band. A ramp is
|
|
137
|
+
// allowed to move; a control's height is not derived from it.
|
|
138
|
+
height: CONTROL_HEIGHT,
|
|
139
|
+
justifyContent: "center",
|
|
133
140
|
paddingHorizontal: 12,
|
|
134
141
|
borderWidth: 1,
|
|
135
142
|
// `colors.border` like every other control — this rested a step
|
package/src/date_stamp.tsx
CHANGED
|
@@ -15,6 +15,12 @@ export interface DateStampProps {
|
|
|
15
15
|
onChange?: (iso: string) => void;
|
|
16
16
|
/** Locale tag for the rendered date. Defaults to the kit's locale. */
|
|
17
17
|
locale?: string;
|
|
18
|
+
/** Drop the year (`08/09` rather than `08/09/2026`). The stamp sits in
|
|
19
|
+
* columns beside dates a caller formats itself, and a year on one side of a
|
|
20
|
+
* row and not the other is the kind of mismatch nobody reports and everyone
|
|
21
|
+
* notices. Same option `formatDate` takes — the format itself stays there,
|
|
22
|
+
* so there is one place that decides what a date looks like. */
|
|
23
|
+
compact?: boolean;
|
|
18
24
|
accessibilityLabel: string;
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -44,7 +50,7 @@ export interface DateStampProps {
|
|
|
44
50
|
* value therefore renders nothing at all.
|
|
45
51
|
*/
|
|
46
52
|
export function DateStamp(props: DateStampProps) {
|
|
47
|
-
const { value, onChange, locale, accessibilityLabel } = props;
|
|
53
|
+
const { value, onChange, locale, compact = false, accessibilityLabel } = props;
|
|
48
54
|
const localeTag = useLocaleTag();
|
|
49
55
|
const tag = locale ?? localeTag;
|
|
50
56
|
const [hovered, setHovered] = useState(false);
|
|
@@ -55,7 +61,7 @@ export function DateStamp(props: DateStampProps) {
|
|
|
55
61
|
// replaced. This is what the close path reads instead.
|
|
56
62
|
const picked = useRef<string | null>(value);
|
|
57
63
|
|
|
58
|
-
const shown = value ? formatDate(value, { locale: tag }) : "";
|
|
64
|
+
const shown = value ? formatDate(value, { locale: tag, compact }) : "";
|
|
59
65
|
|
|
60
66
|
// Read-only, or nothing to show and no way to add one: render the text alone
|
|
61
67
|
// so a caller can drop this into a column without a conditional.
|
package/src/detail_row.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
-
import {
|
|
3
|
+
import { MIN_VALUE_WIDTH } from "./control_surface";
|
|
4
4
|
import { SPACE } from "./spacing";
|
|
5
5
|
import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
|
|
6
6
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
@@ -32,9 +32,9 @@ export interface DetailTableProps {
|
|
|
32
32
|
* and custom rows all sit on the inline-control baseline grid. */
|
|
33
33
|
minHeight?: number;
|
|
34
34
|
/** The usable width the value cell must keep before the table STACKS
|
|
35
|
-
* (default
|
|
36
|
-
* amount + method pair) — side-by-side columns that technically
|
|
37
|
-
* still crush a multi-editor cell. */
|
|
35
|
+
* (default {@link MIN_VALUE_WIDTH}). Raise it when a cell holds MORE than one
|
|
36
|
+
* editor (an amount + method pair) — side-by-side columns that technically
|
|
37
|
+
* fit can still crush a multi-editor cell. */
|
|
38
38
|
minValueWidth?: number;
|
|
39
39
|
/** `DetailRow`s (each inherits the table's columns; row props override). */
|
|
40
40
|
children: ReactNode;
|
|
@@ -68,7 +68,7 @@ export interface DetailTableProps {
|
|
|
68
68
|
* above a full-width value. No prop — the switch is automatic.
|
|
69
69
|
*/
|
|
70
70
|
export function DetailTable(props: DetailTableProps) {
|
|
71
|
-
const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth =
|
|
71
|
+
const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
|
|
72
72
|
const [width, setWidth] = useState<number | null>(null);
|
|
73
73
|
const stacked = width != null && width < labelWidth + minValueWidth + 24;
|
|
74
74
|
return (
|
|
@@ -100,12 +100,17 @@ export interface DetailRowProps extends FieldAnnotationProps {
|
|
|
100
100
|
labelSize?: "xs" | "sm";
|
|
101
101
|
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
102
102
|
minHeight?: number;
|
|
103
|
-
/** The value
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
103
|
+
/** The value is a RAW node — a plain `Text`, a figure, a pair — sitting off the
|
|
104
|
+
* inline-control grid. NOT `InlineStatic` or `InlineSlot`: those compose the
|
|
105
|
+
* grid's own box, so their words already start at the control text inset and
|
|
106
|
+
* centre in the band, and `flat` there re-introduces both offsets it exists
|
|
107
|
+
* to remove. Annotations then align to the value on BOTH axes,
|
|
108
|
+
* because a control's box is what they were offset from and there is no box:
|
|
109
|
+
* they tuck up by the control band's slack, so the perceived gap under the
|
|
110
|
+
* TEXT equals the gap under a chip rather than leaving the band's invisible
|
|
111
|
+
* bottom half reading as a hole; and they drop the control's text inset, so
|
|
112
|
+
* the description starts on the value's own left edge instead of nine pixels
|
|
113
|
+
* inside it. Row height and label centering are unchanged. */
|
|
109
114
|
flat?: boolean;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -160,7 +165,7 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
160
165
|
// text inset, defined once in `field_annotations` and rendered identically by
|
|
161
166
|
// a checklist row's field. A record's field and a checklist's are the same
|
|
162
167
|
// thing on two surfaces; they may not say a fault two different ways.
|
|
163
|
-
const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
|
|
168
|
+
const annotations = <FieldAnnotations description={description} warning={warning} error={error} flat={flat} />;
|
|
164
169
|
if (table?.stacked) {
|
|
165
170
|
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
166
171
|
// `FormField` label (medium, default ink), so a narrow record surface
|
package/src/empty_state.tsx
CHANGED
|
@@ -42,11 +42,11 @@ export function EmptyState(props: EmptyStateProps) {
|
|
|
42
42
|
<Icon name={props.icon} size={28} color={colors.zinc[400]} />
|
|
43
43
|
</View>
|
|
44
44
|
) : null}
|
|
45
|
-
<Text size="sm" color="muted">
|
|
45
|
+
<Text size="sm" color="muted" style={styles.line}>
|
|
46
46
|
{props.message}
|
|
47
47
|
</Text>
|
|
48
48
|
{props.hint ? (
|
|
49
|
-
<Text size="xs" color="muted">
|
|
49
|
+
<Text size="xs" color="muted" style={styles.line}>
|
|
50
50
|
{props.hint}
|
|
51
51
|
</Text>
|
|
52
52
|
) : null}
|
|
@@ -63,4 +63,11 @@ const styles = StyleSheet.create({
|
|
|
63
63
|
icon: {
|
|
64
64
|
marginBottom: 4,
|
|
65
65
|
},
|
|
66
|
+
// The container centres the BOX; without this the text inside it still sets
|
|
67
|
+
// left. Invisible while every line is short enough not to wrap — which is
|
|
68
|
+
// exactly the case a narrow container breaks, leaving a wrapped message
|
|
69
|
+
// reading left-aligned beside a one-line hint reading centred.
|
|
70
|
+
line: {
|
|
71
|
+
textAlign: "center",
|
|
72
|
+
},
|
|
66
73
|
});
|
package/src/error_state.tsx
CHANGED
|
@@ -41,11 +41,11 @@ export function ErrorState(props: ErrorStateProps) {
|
|
|
41
41
|
<View style={styles.icon}>
|
|
42
42
|
<Icon name="triangle-alert" size={28} color={solid("red")} />
|
|
43
43
|
</View>
|
|
44
|
-
<Text size="sm" color="muted">
|
|
44
|
+
<Text size="sm" color="muted" style={styles.line}>
|
|
45
45
|
{props.message}
|
|
46
46
|
</Text>
|
|
47
47
|
{props.detail ? (
|
|
48
|
-
<Text size="xs" color="muted">
|
|
48
|
+
<Text size="xs" color="muted" style={styles.line}>
|
|
49
49
|
{props.detail}
|
|
50
50
|
</Text>
|
|
51
51
|
) : null}
|
|
@@ -66,6 +66,13 @@ const styles = StyleSheet.create({
|
|
|
66
66
|
alignItems: "center",
|
|
67
67
|
gap: 4,
|
|
68
68
|
},
|
|
69
|
+
// Centred like `EmptyState`'s, and for the same reason: the container centres
|
|
70
|
+
// the BOX, not the text inside it. These two swap into one another as a read
|
|
71
|
+
// settles, so a difference here would show up as the copy jumping alignment at
|
|
72
|
+
// the moment a region fails.
|
|
73
|
+
line: {
|
|
74
|
+
textAlign: "center",
|
|
75
|
+
},
|
|
69
76
|
icon: {
|
|
70
77
|
marginBottom: 4,
|
|
71
78
|
},
|
|
@@ -45,10 +45,31 @@ export function hasFieldAnnotation(props: FieldAnnotationProps): boolean {
|
|
|
45
45
|
* they answer different questions ("this is broken" / "this will cost you" / "this is what the
|
|
46
46
|
* field is for"), and hiding the rule that explains the failure is exactly the wrong economy.
|
|
47
47
|
*/
|
|
48
|
-
export
|
|
48
|
+
export interface FieldAnnotationsProps extends FieldAnnotationProps {
|
|
49
|
+
/**
|
|
50
|
+
* The value above is FLAT text, not a painted control — so drop the control's
|
|
51
|
+
* text inset and sit on the value's own left edge.
|
|
52
|
+
*
|
|
53
|
+
* The inset exists to clear a control's border and padding, which is what puts
|
|
54
|
+
* an annotation under the value's WORDS instead of under the editor's
|
|
55
|
+
* invisible box. Under plain text there is no border and no padding to clear,
|
|
56
|
+
* so the same number becomes pure indent: label, value and description end up
|
|
57
|
+
* on THREE left edges where the screen only has two things to say. Nine pixels
|
|
58
|
+
* is small enough to read as sloppiness rather than as structure, and it
|
|
59
|
+
* appears only on the rows whose value happens not to be a control, so a
|
|
60
|
+
* column of fields drifts in and out of alignment down its own length.
|
|
61
|
+
*
|
|
62
|
+
* Hosts already know which kind of value they hold: `DetailRow` passes its
|
|
63
|
+
* `flat`, the same flag that tucks the block up by the control band's slack.
|
|
64
|
+
* One fact about the value, both corrections.
|
|
65
|
+
*/
|
|
66
|
+
flat?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function FieldAnnotations(props: FieldAnnotationsProps) {
|
|
49
70
|
if (!hasFieldAnnotation(props)) return null;
|
|
50
71
|
return (
|
|
51
|
-
<View style={styles.stack}>
|
|
72
|
+
<View style={[styles.stack, props.flat === true && styles.stackFlat]}>
|
|
52
73
|
{props.error != null ? (
|
|
53
74
|
<Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
|
|
54
75
|
{props.error}
|
|
@@ -73,4 +94,6 @@ const styles = StyleSheet.create({
|
|
|
73
94
|
// with the editor's invisible box — and the 2px that separates the lines from each other,
|
|
74
95
|
// which is the same rhythm the host puts between the control and this block.
|
|
75
96
|
stack: { gap: 2, paddingLeft: CONTROL_TEXT_INSET },
|
|
97
|
+
// ...and nothing to clear when the value is flat text — see `flat`.
|
|
98
|
+
stackFlat: { paddingLeft: 0 },
|
|
76
99
|
});
|
package/src/finding.tsx
CHANGED
|
@@ -45,8 +45,8 @@ export interface FindingProps {
|
|
|
45
45
|
/** What was found, in one line, in the reader's own nouns. */
|
|
46
46
|
title: string;
|
|
47
47
|
/**
|
|
48
|
-
* The readings that disagree, in source order. Rendered
|
|
49
|
-
* title as `source value
|
|
48
|
+
* The readings that disagree, in source order. Rendered ONE PER LINE beneath
|
|
49
|
+
* the title, each as `source value` — never joined by a separator.
|
|
50
50
|
*
|
|
51
51
|
* NEITHER is marked as the wrong one. A finding reports that two sources
|
|
52
52
|
* disagree; which one is right is the reader's call, and a struck-through or
|
|
@@ -77,8 +77,15 @@ export interface FindingProps {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* ONE RANKED
|
|
81
|
-
* observation, a briefing item.
|
|
80
|
+
* ONE RANKED DISCREPANCY from a cross-check — a contradiction between two
|
|
81
|
+
* readings, an audit observation, a briefing item.
|
|
82
|
+
*
|
|
83
|
+
* WHOEVER performed the check. Nothing here is AI-specific — the labels are
|
|
84
|
+
* four severity words and "difference" — but this opened "from an AI check",
|
|
85
|
+
* and a rules-derived contradiction (two papers disagreeing, a carrier moving
|
|
86
|
+
* an ETA) reads as out of scope on that wording. It is not: the next author who
|
|
87
|
+
* believes it hand-rolls a Callout and loses the ranking, the delta and the
|
|
88
|
+
* readings line.
|
|
82
89
|
*
|
|
83
90
|
* The readings sit at `sm`, the same size as the title. They were `xs` — a
|
|
84
91
|
* miniature that said "secondary" by being hard to read, which is the wrong
|
|
@@ -134,11 +141,6 @@ export function Finding(props: FindingProps) {
|
|
|
134
141
|
<View style={styles.readings}>
|
|
135
142
|
{readings.map((reading, index) => (
|
|
136
143
|
<View key={`${reading.source}-${index}`} style={styles.reading}>
|
|
137
|
-
{index > 0 ? (
|
|
138
|
-
<Text size="sm" color="muted">
|
|
139
|
-
·
|
|
140
|
-
</Text>
|
|
141
|
-
) : null}
|
|
142
144
|
<SourceName label={reading.source} onOpen={props.onOpenSource} />
|
|
143
145
|
<Text size="sm" weight="medium" tabular numberOfLines={1}>
|
|
144
146
|
{reading.value}
|
|
@@ -189,6 +191,13 @@ const styles = StyleSheet.create({
|
|
|
189
191
|
// instance — the dot marks the left edge and the figure the right, so a
|
|
190
192
|
// reader scanning a stack gets rank and magnitude in one pass.
|
|
191
193
|
delta: { flexShrink: 0 },
|
|
192
|
-
|
|
194
|
+
// ONE READING PER LINE. They were joined inline by a ` · `, which fails twice:
|
|
195
|
+
// a middot claims a relation while refusing to name it (the templated-metadata
|
|
196
|
+
// tell `docs/reviewing.md` bans outright), and it is the one mark a screen
|
|
197
|
+
// reader drops — so the separator carrying "these two disagree" never survived
|
|
198
|
+
// being read aloud, the same defect `CommentsButton` was fixed for. It also
|
|
199
|
+
// wrapped: at phone width a line could BEGIN with the separator. Stacking pairs
|
|
200
|
+
// each source with its own value at every width and needs no separator at all.
|
|
201
|
+
readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
|
|
193
202
|
reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
|
|
194
203
|
});
|
package/src/format_money.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface FormatMoneyOptions {
|
|
|
5
5
|
* "486 tr ₫". For stat strips/cards where the full figure lives in the
|
|
6
6
|
* table below — never for the table itself. */
|
|
7
7
|
compact?: boolean;
|
|
8
|
+
/** Significant digits below the currency's minor unit. `Intl`'s currency
|
|
9
|
+
* style floors at that unit — 2 for USD — so a genuine per-unit price like
|
|
10
|
+
* an LCL rate of 0,019 US$/kg renders as `0,02`: a 5% error, printed as
|
|
11
|
+
* fact, with nothing on screen saying it was rounded. Set this on a UNIT
|
|
12
|
+
* price; leave it off for a total, where two decimals is the contract. */
|
|
13
|
+
maxFractionDigits?: number;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
/**
|
|
@@ -29,10 +35,18 @@ export function formatCompactNumber(value: number, locale = "vi-VN"): string {
|
|
|
29
35
|
* or the ₫ suffix.
|
|
30
36
|
*/
|
|
31
37
|
export function formatMoney(value: number, options: FormatMoneyOptions = {}): string {
|
|
32
|
-
const { locale = "vi-VN", currency = "VND", compact = false } = options;
|
|
38
|
+
const { locale = "vi-VN", currency = "VND", compact = false, maxFractionDigits } = options;
|
|
33
39
|
if (compact && Math.abs(value) >= 1_000_000) {
|
|
34
40
|
const suffixed = formatCompactNumber(value, locale);
|
|
35
41
|
return currency === "VND" ? `${suffixed} ₫` : `${suffixed} ${currency}`;
|
|
36
42
|
}
|
|
37
|
-
return value.toLocaleString(locale, {
|
|
43
|
+
return value.toLocaleString(locale, {
|
|
44
|
+
style: "currency",
|
|
45
|
+
currency,
|
|
46
|
+
// Raising the MAXIMUM alone is enough, and is what keeps a total looking
|
|
47
|
+
// like money: Intl holds its own minimum (2 for USD, 0 for VND), so 0,019
|
|
48
|
+
// gains its digits while 2750 still renders 2.750,00. Unset, both stay at
|
|
49
|
+
// the currency's own contract.
|
|
50
|
+
...(maxFractionDigits != null ? { maximumFractionDigits: maxFractionDigits } : {}),
|
|
51
|
+
});
|
|
38
52
|
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -259,6 +259,7 @@ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; activ
|
|
|
259
259
|
return (hovered: boolean): StyleProp<ViewStyle>[] => [
|
|
260
260
|
styles.view,
|
|
261
261
|
(o.variant === "bare" || o.disabled) && styles.viewBare,
|
|
262
|
+
o.disabled === true && styles.viewInert,
|
|
262
263
|
// Keyed on the VARIANT alone, never on `disabled` — a disabled `framed`
|
|
263
264
|
// field shares the line above (it must not promise a press) but stays in its
|
|
264
265
|
// column with the enabled fields beside it. Bleeding that one would pull a
|
|
@@ -396,9 +397,9 @@ interface InlineEditViewProps {
|
|
|
396
397
|
/**
|
|
397
398
|
* The view-mode box of an inline-editable field: the value as plain text on the
|
|
398
399
|
* shared control surface — white with a 1px border when `framed`, frameless
|
|
399
|
-
* until hover when `bare`. It hovers via its BORDER, never a grey wash, and
|
|
400
|
-
*
|
|
401
|
-
*
|
|
400
|
+
* until hover when `bare`. It hovers via its BORDER, never a grey wash, and it
|
|
401
|
+
* takes the pointer cursor every other control takes. The border is present at
|
|
402
|
+
* every state (merely
|
|
402
403
|
* transparent when `bare` rests), so swapping to an input, or floating a
|
|
403
404
|
* dropdown above it, never shifts the layout. Used by `InlineEditFrame`, and as
|
|
404
405
|
* the overlay trigger for the select/date inline editors (it forwards ref +
|
|
@@ -459,7 +460,7 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
459
460
|
onFocus={onFocus}
|
|
460
461
|
accessibilityRole="button"
|
|
461
462
|
accessibilityLabel={accessibilityLabel}
|
|
462
|
-
style={[styles.viewInner, multilineBox(numberOfLines)]}
|
|
463
|
+
style={[styles.viewInner, disabled === true && styles.viewInert, multilineBox(numberOfLines)]}
|
|
463
464
|
>
|
|
464
465
|
{content}
|
|
465
466
|
</Pressable>
|
|
@@ -678,14 +679,33 @@ const styles = StyleSheet.create({
|
|
|
678
679
|
flexDirection: "row",
|
|
679
680
|
alignItems: "center",
|
|
680
681
|
gap: 6,
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
682
|
+
// THE POINTER, like every other control in the kit.
|
|
683
|
+
//
|
|
684
|
+
// This box used to force the arrow back, on the reasoning that an inline
|
|
685
|
+
// editor is an input rather than a button and the chip plus hover-border
|
|
686
|
+
// already carried the affordance. Both halves fail. `bare` HAS no chip —
|
|
687
|
+
// nothing at rest by design — so the whole affordance was a 1px edge that
|
|
688
|
+
// appears on hover, which is invisible to touch and to anyone who never
|
|
689
|
+
// happens to sweep the value; a member's actual question was "how am I
|
|
690
|
+
// supposed to edit this?". And the arrow does not read as "input", it
|
|
691
|
+
// reads as INERT: it is what the page's own prose gets, so the one thing
|
|
692
|
+
// on the row that is theirs to change looked like the one thing that
|
|
693
|
+
// wasn't.
|
|
694
|
+
//
|
|
695
|
+
// The I-beam is not the alternative. It promises a caret and a selection,
|
|
696
|
+
// and `userSelect: "none"` below deliberately denies both so a drag edits
|
|
697
|
+
// instead of selecting — a false promise in the same family as the arrow.
|
|
698
|
+
// What is true of every variant is that pressing DOES something, which is
|
|
699
|
+
// exactly what the pointer says.
|
|
700
|
+
cursor: "pointer",
|
|
685
701
|
},
|
|
686
702
|
// Nothing at rest — the frame arrives on hover. Also how a DISABLED field
|
|
687
703
|
// rests, whatever its variant: an inert value must not promise a press.
|
|
688
704
|
viewBare: { backgroundColor: "transparent", borderColor: "transparent" },
|
|
705
|
+
// ...and the cursor is half of that promise, so it goes back to the arrow
|
|
706
|
+
// for a disabled field only. `viewBare` cannot carry it: a `bare` field is
|
|
707
|
+
// enabled and wears the same style.
|
|
708
|
+
viewInert: { cursor: "auto" },
|
|
689
709
|
// A bare field at rest looks like TEXT, so it aligns like text: its glyphs sit
|
|
690
710
|
// on the column, not its invisible box. The frame's 8px inset is real padding
|
|
691
711
|
// the eye cannot see at rest, and it put every bare value 8px right of the
|
|
@@ -716,7 +736,7 @@ const styles = StyleSheet.create({
|
|
|
716
736
|
// The value region inside a verb-hosting resting field: carries the row layout
|
|
717
737
|
// the surface would otherwise own. `userSelect` here rather than via the
|
|
718
738
|
// `FocusRingPressable` prop — a drag across a value must edit, not select text.
|
|
719
|
-
viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none"
|
|
739
|
+
viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none" } as ViewStyle) },
|
|
720
740
|
// Open (popover showing): the resting surface plus the 2px active ring — the
|
|
721
741
|
// one thing that separates open from at-rest, and identical to a focused input.
|
|
722
742
|
viewActive: {
|
package/src/inline_select.tsx
CHANGED
|
@@ -26,6 +26,11 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
|
|
|
26
26
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
27
27
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
28
28
|
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
29
|
+
/** A second line under an option's label in the dropdown — a driver's phone
|
|
30
|
+
* under their name, a code under a description. `OptionList` has always
|
|
31
|
+
* rendered this two-line `MenuListItem`; this only forwards it, which is why
|
|
32
|
+
* callers were hand-rebuilding the anatomy through `renderOptionContent`. */
|
|
33
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
29
34
|
/** Render ONE selected option as its resting chip — single: the value; multi:
|
|
30
35
|
* each tag. Falls back to `renderOptionContent`, then the plain label (single) /
|
|
31
36
|
* a zinc `Badge` (multi). The one seam for "how the selection looks". */
|
|
@@ -135,7 +140,7 @@ function InlineSelectShell(props: {
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
|
|
138
|
-
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
|
|
143
|
+
const { options, renderOptionContent, getOptionDescription, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
|
|
139
144
|
const labels = useLoticsLocale().inline;
|
|
140
145
|
const [open, setOpen] = useState(autoFocus);
|
|
141
146
|
const [saving, setSaving] = useState(false);
|
|
@@ -191,6 +196,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
191
196
|
>
|
|
192
197
|
<OptionList<T, true, D>
|
|
193
198
|
multi
|
|
199
|
+
getOptionDescription={getOptionDescription}
|
|
194
200
|
search={{ mode: searchMode }}
|
|
195
201
|
options={options}
|
|
196
202
|
value={draft}
|
|
@@ -237,6 +243,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
237
243
|
error={error}
|
|
238
244
|
>
|
|
239
245
|
<OptionList<T, false, D>
|
|
246
|
+
getOptionDescription={getOptionDescription}
|
|
240
247
|
search={{ mode: searchMode }}
|
|
241
248
|
options={options}
|
|
242
249
|
value={value}
|
|
@@ -184,19 +184,18 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
184
184
|
// Everything the resting VIEW used to draw, the input now draws, because
|
|
185
185
|
// there is no resting view left to draw it. Both of these were its job:
|
|
186
186
|
//
|
|
187
|
-
// `
|
|
188
|
-
//
|
|
189
|
-
//
|
|
187
|
+
// `variant` — the resting frame, or none. It goes over as the PROP, not
|
|
188
|
+
// as a `style` override: the two are not equivalent, because `style` is
|
|
189
|
+
// applied after the hover rule and so erased the hover edge that is a
|
|
190
|
+
// bare field's only affordance. The field owns its own variants.
|
|
190
191
|
//
|
|
191
192
|
// `struck` — a completed item's value, line-through and muted. This was
|
|
192
193
|
// applied in `fieldContent`, which only ever runs in the resting view, so
|
|
193
194
|
// moving to one element silently dropped it: a done task on the board kept
|
|
194
195
|
// its title upright. Anything else the view used to render has to move the
|
|
195
196
|
// same way, or it goes the same way — quietly.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null,
|
|
199
|
-
]}
|
|
197
|
+
variant={variant}
|
|
198
|
+
style={struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null}
|
|
200
199
|
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
201
200
|
seamless={actions != null}
|
|
202
201
|
/>
|
package/src/ledger.tsx
CHANGED
|
@@ -44,8 +44,11 @@ function useLedger(): LedgerContextValue {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** Signed display: negatives render as "− <abs>" (a receipt against charges).
|
|
47
|
-
* `n + 0` normalizes -0 (a zero receipts sum passed negated) to plain 0.
|
|
48
|
-
|
|
47
|
+
* `n + 0` normalizes -0 (a zero receipts sum passed negated) to plain 0.
|
|
48
|
+
* Exported for `RunningLedger`, which shares this exact glyph rule for every
|
|
49
|
+
* money/quantity figure it renders — one definition, so the two components'
|
|
50
|
+
* output can never drift into two different negative-number conventions. */
|
|
51
|
+
export function signed(format: (n: number) => string, n: number) {
|
|
49
52
|
return n < 0 ? `− ${format(-n)}` : format(n + 0);
|
|
50
53
|
}
|
|
51
54
|
|
package/src/locale.tsx
CHANGED
|
@@ -74,11 +74,23 @@ export interface LoticsLocale {
|
|
|
74
74
|
clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
|
|
75
75
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
76
76
|
ledger: { rowDetails: (label: string) => string };
|
|
77
|
-
/** `
|
|
77
|
+
/** `RunningLedger`: the closing line's label ("Số dư hiện tại", "Tồn kho
|
|
78
|
+
* hiện tại") — the ledger's one derived, always-current figure. */
|
|
79
|
+
runningLedger: { currentBalance: string };
|
|
78
80
|
/** `Step`/`ChecklistItem`'s toggleable MARKER, when the caller names no
|
|
79
81
|
* label of its own. A pipeline stage always passes its title, so this is the
|
|
80
82
|
* bare-`Step` fallback. */
|
|
81
83
|
stepper: { complete: string; progress: string };
|
|
84
|
+
/** `StepProgress`: the built-in caption over NAMED stages, and the
|
|
85
|
+
* screen-reader position when there is no caption to borrow. The counts are
|
|
86
|
+
* interpolated by the pack rather than concatenated at the call site, because
|
|
87
|
+
* where the number sits in the sentence is a property of the language. */
|
|
88
|
+
stepProgress: {
|
|
89
|
+
complete: (total: number) => string;
|
|
90
|
+
stage: (name: string, index: number, total: number) => string;
|
|
91
|
+
none: (total: number) => string;
|
|
92
|
+
position: (index: number, total: number) => string;
|
|
93
|
+
};
|
|
82
94
|
/** `ChecklistGroup`'s disclosure — the small pressable text that shows or hides
|
|
83
95
|
* a phase's rows. The kit owns the wording so one app does not say "Show" while
|
|
84
96
|
* the next says "Expand" for the same gesture. */
|
|
@@ -89,6 +101,8 @@ export interface LoticsLocale {
|
|
|
89
101
|
* because word order is not shared: English puts the verb first, and a
|
|
90
102
|
* language that does not would otherwise be stuck with a prefix. */
|
|
91
103
|
textDisclosure: { show: (label: string) => string; hide: (label: string) => string };
|
|
104
|
+
/** The info-popover trigger's screen-reader name on a heading — every level of
|
|
105
|
+
* `SectionHeading*`, and `CardHeaderTitle`, which names the same altitude. */
|
|
92
106
|
sectionHeading: { info: string };
|
|
93
107
|
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
94
108
|
* phrased identically everywhere instead of hand-written per app. */
|
|
@@ -196,6 +210,10 @@ export interface LoticsLocale {
|
|
|
196
210
|
* a SOURCE with different words. */
|
|
197
211
|
fileRow: { open: (name: string) => string };
|
|
198
212
|
sources: { open: (label: string) => string; heading: string };
|
|
213
|
+
/** `RelatedRecordRow`: the accessible name for its press target, when the
|
|
214
|
+
* caller has not overridden it — "<kind> <code>", e.g. "Kế hoạch sản xuất
|
|
215
|
+
* KH-2026-0001". */
|
|
216
|
+
relatedRecordRow: { open: (kind: string, code: string) => string };
|
|
199
217
|
/** `Counter`: the two steppers, named for the value they move. */
|
|
200
218
|
counter: { decrease: (label: string) => string; increase: (label: string) => string };
|
|
201
219
|
/** `AllocationRow`: the a11y name of the amount input (naming its destination),
|
|
@@ -292,7 +310,14 @@ export const en: LoticsLocale = {
|
|
|
292
310
|
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
293
311
|
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
294
312
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
313
|
+
runningLedger: { currentBalance: "Current balance" },
|
|
295
314
|
stepper: { complete: "Complete step", progress: "Progress" },
|
|
315
|
+
stepProgress: {
|
|
316
|
+
complete: (total) => `Complete (${total}/${total})`,
|
|
317
|
+
stage: (name, index, total) => `${name} (${index}/${total})`,
|
|
318
|
+
none: (total) => `0/${total}`,
|
|
319
|
+
position: (index, total) => `${index} of ${total}`,
|
|
320
|
+
},
|
|
296
321
|
checklist: { expand: "Show", collapse: "Hide" },
|
|
297
322
|
textDisclosure: { show: (label) => `Show ${label}`, hide: (label) => `Hide ${label}` },
|
|
298
323
|
sectionHeading: { info: "About this data" },
|
|
@@ -392,6 +417,7 @@ export const en: LoticsLocale = {
|
|
|
392
417
|
infoPopover: { more: "More information", about: (label) => `About ${label}` },
|
|
393
418
|
fileRow: { open: (name) => `Open ${name}` },
|
|
394
419
|
sources: { open: (label) => `Open ${label}`, heading: "Sources" },
|
|
420
|
+
relatedRecordRow: { open: (kind, code) => `Open ${kind} ${code}` },
|
|
395
421
|
counter: { decrease: (label) => `Decrease ${label}`, increase: (label) => `Increase ${label}` },
|
|
396
422
|
allocationRow: {
|
|
397
423
|
allocateTo: (label) => `Allocate to ${label}`,
|
|
@@ -471,7 +497,14 @@ export const vi: LoticsLocale = {
|
|
|
471
497
|
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
472
498
|
clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
|
|
473
499
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
500
|
+
runningLedger: { currentBalance: "Số dư hiện tại" },
|
|
474
501
|
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
502
|
+
stepProgress: {
|
|
503
|
+
complete: (total) => `Hoàn tất (${total}/${total})`,
|
|
504
|
+
stage: (name, index, total) => `${name} (${index}/${total})`,
|
|
505
|
+
none: (total) => `0/${total}`,
|
|
506
|
+
position: (index, total) => `${index} trên ${total}`,
|
|
507
|
+
},
|
|
475
508
|
checklist: { expand: "Mở", collapse: "Thu gọn" },
|
|
476
509
|
textDisclosure: { show: (label) => `Xem ${label}`, hide: (label) => `Ẩn ${label}` },
|
|
477
510
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
@@ -571,6 +604,7 @@ export const vi: LoticsLocale = {
|
|
|
571
604
|
infoPopover: { more: "Thông tin thêm", about: (label) => `Thông tin về ${label}` },
|
|
572
605
|
fileRow: { open: (name) => `Mở ${name}` },
|
|
573
606
|
sources: { open: (label) => `Mở ${label}`, heading: "Nguồn" },
|
|
607
|
+
relatedRecordRow: { open: (kind, code) => `Mở ${kind} ${code}` },
|
|
574
608
|
counter: { decrease: (label) => `Giảm ${label}`, increase: (label) => `Tăng ${label}` },
|
|
575
609
|
allocationRow: {
|
|
576
610
|
allocateTo: (label) => `Phân bổ vào ${label}`,
|
package/src/number_input.tsx
CHANGED
|
@@ -18,6 +18,12 @@ export interface NumberInputProps {
|
|
|
18
18
|
disabled?: boolean;
|
|
19
19
|
testID?: string;
|
|
20
20
|
accessibilityLabel?: string;
|
|
21
|
+
/** Hint text shown while the field is empty — a unit, a shape, an example
|
|
22
|
+
* ("0,00", "kg"). `InlineNumberInput` has always had one; without it here the
|
|
23
|
+
* only hint slot left on a `FormField` is `description`, which renders
|
|
24
|
+
* BETWEEN label and input and so drops this field's input a line below its
|
|
25
|
+
* neighbour's in a two-column form grid. */
|
|
26
|
+
placeholder?: string;
|
|
21
27
|
/** The HOST paints the surface — drop this input's own border, fill, focus ring
|
|
22
28
|
* AND horizontal padding, so the page draws one box and the text lands on the
|
|
23
29
|
* host's inset rather than 8px further in. Set by an inline editor whose field
|
|
@@ -37,7 +43,7 @@ export interface NumberInputProps {
|
|
|
37
43
|
* number in place on a record use `InlineNumberInput`.
|
|
38
44
|
*/
|
|
39
45
|
export function NumberInput(props: NumberInputProps) {
|
|
40
|
-
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, seamless } = props;
|
|
46
|
+
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, placeholder, seamless } = props;
|
|
41
47
|
const binding = useFormField();
|
|
42
48
|
const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
43
49
|
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
@@ -52,6 +58,7 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
52
58
|
aria-describedby={describedBy}
|
|
53
59
|
aria-invalid={binding?.invalid || undefined}
|
|
54
60
|
value={value ?? ""}
|
|
61
|
+
placeholder={placeholder}
|
|
55
62
|
onChange={(e) =>
|
|
56
63
|
e.target.value !== "" ? onValueChange(Number(e.target.value)) : onValueChange(null)
|
|
57
64
|
}
|
package/src/reference_field.tsx
CHANGED
|
@@ -423,10 +423,17 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
423
423
|
accessibilityLabel={`${t.change} — ${name}`}
|
|
424
424
|
onPress={() => { setPeekOpen(false); onChange(); }}
|
|
425
425
|
/>
|
|
426
|
-
{/*
|
|
427
|
-
|
|
426
|
+
{/* Same resting surface as `change` beside it. It had NO
|
|
427
|
+
color — which renders transparent, borderless and
|
|
428
|
+
undecorated, i.e. the hover-only affordance composition.md
|
|
429
|
+
bans: invisible to keyboard and touch, and a singleton
|
|
430
|
+
no-ground among grounded siblings, which a reader parses
|
|
431
|
+
before they read any label. Being the least-reached verb is
|
|
432
|
+
said by PLACEMENT — second in the left group, far from the
|
|
433
|
+
primary — not by drawing nothing. */}
|
|
428
434
|
<Button
|
|
429
435
|
title={t.clear}
|
|
436
|
+
color="secondary"
|
|
430
437
|
accessibilityLabel={`${t.clear} — ${name}`}
|
|
431
438
|
onPress={() => { setPeekOpen(false); onClear(); }}
|
|
432
439
|
/>
|