@lotics/ui 46.2.0 → 46.8.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 +38 -1
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +11 -0
- package/docs/catalog.md +217 -21
- package/docs/composition.md +74 -6
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +93 -30
- package/docs/testing.md +6 -0
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -258
- package/src/accordion.tsx +7 -1
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +373 -0
- package/src/chip_group.tsx +57 -1
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/file_row.tsx +98 -5
- package/src/icon.tsx +6 -0
- package/src/inline_edit.tsx +54 -10
- package/src/inline_number_input.tsx +5 -1
- package/src/inline_text_input.tsx +1 -1
- package/src/line_chart.tsx +2 -2
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/reference_field.tsx +36 -13
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
- package/src/line_chart_labels.ts +0 -32
package/src/file_row.tsx
CHANGED
|
@@ -3,11 +3,25 @@ import { Pressable, StyleSheet, View } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { FileBadge } from "./file_badge";
|
|
6
|
+
import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
|
|
6
7
|
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
7
8
|
import { useFocusRing } from "./use_focus_ring";
|
|
9
|
+
import { isImageMimeType } from "./mime";
|
|
8
10
|
import { useLoticsLocale } from "./locale";
|
|
11
|
+
import { FieldAnnotations, type FieldAnnotationProps } from "./field_annotations";
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A document-checklist row is a FIELD SURFACE — an expected document is a value
|
|
15
|
+
* the reader supplies — so it carries the kit's one annotation anatomy
|
|
16
|
+
* (`description` / `warning` / `error`), the same block `DetailRow` renders.
|
|
17
|
+
*
|
|
18
|
+
* Without it a row whose document FAILS could only say so in `meta`, which is
|
|
19
|
+
* muted single-line prose, or by demoting the fault to a callout about the whole
|
|
20
|
+
* SECTION — which says something different: the callout summarises, the field
|
|
21
|
+
* pinpoints. It is `flat`: the value above is the file's own name, not a painted
|
|
22
|
+
* control, so the annotation sits on that name's left edge with no inset.
|
|
23
|
+
*/
|
|
24
|
+
export interface FileRowProps extends FieldAnnotationProps {
|
|
11
25
|
/** The file / document name — the primary line. */
|
|
12
26
|
name: string;
|
|
13
27
|
/**
|
|
@@ -25,6 +39,32 @@ export interface FileRowProps {
|
|
|
25
39
|
/** File type → the FileBadge glyph + color. Omit (with `placeholder`) for an
|
|
26
40
|
* expected-but-not-yet-provided document. */
|
|
27
41
|
mimeType?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The stored file, so an IMAGE shows the scan itself where the glyph would be.
|
|
44
|
+
*
|
|
45
|
+
* A checklist of photographed paperwork — a permit, a certificate, an ID — is
|
|
46
|
+
* a list of documents that all carry the SAME badge, so the badge stops
|
|
47
|
+
* telling one row from another and only says "this is a picture". The picture
|
|
48
|
+
* says which picture. A non-image (a PDF) falls back to the badge, so a mixed
|
|
49
|
+
* list keeps one footprint down the column.
|
|
50
|
+
*
|
|
51
|
+
* It replaces the badge rather than sitting beside it: two marks on one row is
|
|
52
|
+
* the same fact twice, and `leading` is reserved for a mark ABOUT the row (a
|
|
53
|
+
* `DiffMark`, a checkbox) which must line up outside the file's own identity.
|
|
54
|
+
*/
|
|
55
|
+
file?: DisplayFile;
|
|
56
|
+
/**
|
|
57
|
+
* Makes the THUMBNAIL its own press target, separate from the row's.
|
|
58
|
+
*
|
|
59
|
+
* A document row often has two different destinations: the row opens what the
|
|
60
|
+
* row is ABOUT (expand its fields, drill to the record), while the picture
|
|
61
|
+
* opens the picture. Without this the row's single door swallows both, so the
|
|
62
|
+
* only way to see the scan is whatever the row happens to do.
|
|
63
|
+
*
|
|
64
|
+
* It is a sibling press, not a nested one — the thumbnail sits outside the
|
|
65
|
+
* row's own `Pressable`, so this never puts a button inside a button.
|
|
66
|
+
*/
|
|
67
|
+
onOpenFile?: () => void;
|
|
28
68
|
/** Ghost badge — an expected document that hasn't been provided yet. */
|
|
29
69
|
placeholder?: boolean;
|
|
30
70
|
/** Marks the badge as a template (TMPL). */
|
|
@@ -36,6 +76,12 @@ export interface FileRowProps {
|
|
|
36
76
|
* control. An independently-interactive SIBLING: never swallowed by the row
|
|
37
77
|
* press (a button never nests in the door button). */
|
|
38
78
|
trailing?: React.ReactNode;
|
|
79
|
+
/**
|
|
80
|
+
* What the row IS — a status `Badge`, a verdict. Rides INSIDE the press door,
|
|
81
|
+
* because a status is part of what the row says and pressing it should open
|
|
82
|
+
* the row. `trailing` is for what you DO to the row and stays outside.
|
|
83
|
+
*/
|
|
84
|
+
status?: React.ReactNode;
|
|
39
85
|
/**
|
|
40
86
|
* Leading slot, BEFORE the badge — a `DiffMark` saying what happened to this
|
|
41
87
|
* document, a selection checkbox, a status dot.
|
|
@@ -79,11 +125,17 @@ export function FileRow({
|
|
|
79
125
|
meta,
|
|
80
126
|
mimeType,
|
|
81
127
|
placeholder,
|
|
128
|
+
file,
|
|
129
|
+
onOpenFile,
|
|
82
130
|
isTemplate,
|
|
83
131
|
onPress,
|
|
84
132
|
trailing,
|
|
85
133
|
leading,
|
|
86
134
|
size = "sm",
|
|
135
|
+
description,
|
|
136
|
+
warning,
|
|
137
|
+
error,
|
|
138
|
+
status,
|
|
87
139
|
}: FileRowProps) {
|
|
88
140
|
const locale = useLoticsLocale();
|
|
89
141
|
const md = size === "md";
|
|
@@ -97,9 +149,28 @@ export function FileRow({
|
|
|
97
149
|
onMouseLeave: () => setHovered(false),
|
|
98
150
|
} as object;
|
|
99
151
|
|
|
100
|
-
const
|
|
152
|
+
const mark = (
|
|
153
|
+
<>
|
|
154
|
+
{file && isImageMimeType(file.mimeType) ? (
|
|
155
|
+
/* A PHOTO is worth looking at, so it gets more room than a glyph would
|
|
156
|
+
earn — but only on `md`, and only for an image: a non-image above
|
|
157
|
+
`COMPACT_THUMBNAIL_SIZE` turns into a CARD that prints the filename,
|
|
158
|
+
which the row already says. */
|
|
159
|
+
<FileThumbnail
|
|
160
|
+
file={file}
|
|
161
|
+
size={md ? IMAGE_THUMB_MD : 30}
|
|
162
|
+
disablePress={!onOpenFile}
|
|
163
|
+
onPress={onOpenFile}
|
|
164
|
+
accessibilityLabel={name}
|
|
165
|
+
/>
|
|
166
|
+
) : (
|
|
167
|
+
<FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
|
|
168
|
+
)}
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const body = (
|
|
101
173
|
<>
|
|
102
|
-
<FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
|
|
103
174
|
<View style={styles.text}>
|
|
104
175
|
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
105
176
|
{name}
|
|
@@ -114,7 +185,15 @@ export function FileRow({
|
|
|
114
185
|
) : (
|
|
115
186
|
meta
|
|
116
187
|
)}
|
|
188
|
+
<FieldAnnotations description={description} warning={warning} error={error} flat />
|
|
117
189
|
</View>
|
|
190
|
+
{/* STATUS rides INSIDE the door, unlike `trailing`. What a row IS belongs
|
|
191
|
+
to the row, so pressing its status opens the row like pressing its
|
|
192
|
+
name does; what you DO to a row is a control and stays a sibling.
|
|
193
|
+
Put a status Badge in `trailing` instead and it becomes a dead patch
|
|
194
|
+
in the middle of a pressable row — the reader presses the most
|
|
195
|
+
eye-catching thing on the line and nothing happens. */}
|
|
196
|
+
{status == null ? null : <View style={styles.status}>{status}</View>}
|
|
118
197
|
</>
|
|
119
198
|
);
|
|
120
199
|
|
|
@@ -122,7 +201,8 @@ export function FileRow({
|
|
|
122
201
|
return (
|
|
123
202
|
<View style={[styles.row, md && styles.rowMd]}>
|
|
124
203
|
{leading}
|
|
125
|
-
{
|
|
204
|
+
{mark}
|
|
205
|
+
{body}
|
|
126
206
|
{trailing}
|
|
127
207
|
</View>
|
|
128
208
|
);
|
|
@@ -139,6 +219,11 @@ export function FileRow({
|
|
|
139
219
|
{...mouseProps}
|
|
140
220
|
>
|
|
141
221
|
{leading}
|
|
222
|
+
{/* With its own press, the mark is a SIBLING of the door — nesting it
|
|
223
|
+
would put a button inside a button, and the browser would rather
|
|
224
|
+
reparent the DOM than render it. Without one it rides inside, where
|
|
225
|
+
pressing the file's face opens the row like the rest of it. */}
|
|
226
|
+
{onOpenFile ? mark : null}
|
|
142
227
|
<Pressable
|
|
143
228
|
onPress={onPress}
|
|
144
229
|
{...focusProps}
|
|
@@ -148,7 +233,8 @@ export function FileRow({
|
|
|
148
233
|
accessibilityLabel={locale.fileRow.open(name)}
|
|
149
234
|
style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
150
235
|
>
|
|
151
|
-
{
|
|
236
|
+
{onOpenFile ? null : mark}
|
|
237
|
+
{body}
|
|
152
238
|
</Pressable>
|
|
153
239
|
{trailing}
|
|
154
240
|
</View>
|
|
@@ -168,6 +254,12 @@ export function FileRow({
|
|
|
168
254
|
* `TableRow` 52, this component's own `md` 56 — which is what "squeezed"
|
|
169
255
|
* literally was: a file line packed tighter than any list it sits beside.
|
|
170
256
|
*/
|
|
257
|
+
/** An image thumbnail on the DOCUMENT-DESK row. Bigger than the 38 a badge
|
|
258
|
+
* takes, because a scan is read rather than recognised — at badge size a
|
|
259
|
+
* photographed certificate is an indistinct grey rectangle, and the whole
|
|
260
|
+
* reason to show the picture is to tell one document from another. */
|
|
261
|
+
export const IMAGE_THUMB_MD = 56;
|
|
262
|
+
|
|
171
263
|
const ROW_MIN_HEIGHT = 48;
|
|
172
264
|
const ROW_PAD_Y = 6;
|
|
173
265
|
|
|
@@ -202,5 +294,6 @@ const styles = StyleSheet.create({
|
|
|
202
294
|
rowPressed: { backgroundColor: colors.zinc["100"] },
|
|
203
295
|
// The accessible "Open" door — fills the row left of the trailing sibling.
|
|
204
296
|
door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12, cursor: CURSOR_DEFAULT },
|
|
297
|
+
status: { marginLeft: "auto", paddingLeft: 12 },
|
|
205
298
|
text: { flex: 1, gap: 1 },
|
|
206
299
|
});
|
package/src/icon.tsx
CHANGED
|
@@ -71,6 +71,9 @@ import Columns3Cog from "lucide-react-native/dist/esm/icons/columns-3-cog";
|
|
|
71
71
|
import Construction from "lucide-react-native/dist/esm/icons/construction";
|
|
72
72
|
import Copy from "lucide-react-native/dist/esm/icons/copy";
|
|
73
73
|
import CreditCard from "lucide-react-native/dist/esm/icons/credit-card";
|
|
74
|
+
import Banknote from "lucide-react-native/dist/esm/icons/banknote";
|
|
75
|
+
import ArrowRightLeft from "lucide-react-native/dist/esm/icons/arrow-right-left";
|
|
76
|
+
import HandCoins from "lucide-react-native/dist/esm/icons/hand-coins";
|
|
74
77
|
import Database from "lucide-react-native/dist/esm/icons/database";
|
|
75
78
|
import Download from "lucide-react-native/dist/esm/icons/download";
|
|
76
79
|
import Ellipsis from "lucide-react-native/dist/esm/icons/ellipsis";
|
|
@@ -271,6 +274,9 @@ const iconComponents = {
|
|
|
271
274
|
construction: Construction,
|
|
272
275
|
copy: Copy,
|
|
273
276
|
"credit-card": CreditCard,
|
|
277
|
+
banknote: Banknote,
|
|
278
|
+
"arrow-right-left": ArrowRightLeft,
|
|
279
|
+
"hand-coins": HandCoins,
|
|
274
280
|
database: Database,
|
|
275
281
|
download: Download,
|
|
276
282
|
ellipsis: Ellipsis,
|
package/src/inline_edit.tsx
CHANGED
|
@@ -207,6 +207,12 @@ interface InlineEditFrameProps {
|
|
|
207
207
|
* exists to avoid is not perceptible. `InlineEditView` already renders both;
|
|
208
208
|
* this only stops the frame from narrowing what it forwards. */
|
|
209
209
|
display: string | ReactNode;
|
|
210
|
+
/** Which edge the RESTING value sits on. Money in a column takes "right", so
|
|
211
|
+
* a typed figure shares its edge with the derived one beside it; anything
|
|
212
|
+
* else keeps the default. reviewing.md probe 6: a sound money column lands
|
|
213
|
+
* on TWO right edges (the typed one, the computed one) — a broken one lands
|
|
214
|
+
* on as many as it has rows, and the reader can no longer add it up by eye. */
|
|
215
|
+
align?: "left" | "right";
|
|
210
216
|
placeholder?: string;
|
|
211
217
|
/** Enter edit mode (the view is pressed). */
|
|
212
218
|
onBegin: () => void;
|
|
@@ -256,20 +262,26 @@ interface InlineEditFrameProps {
|
|
|
256
262
|
*/
|
|
257
263
|
function fieldContent(o: {
|
|
258
264
|
display: string | ReactNode;
|
|
265
|
+
/** Which edge the RESTING value sits on. Money in a column takes "right", so
|
|
266
|
+
* a typed figure shares its edge with the derived one beside it; anything
|
|
267
|
+
* else keeps the default. reviewing.md probe 6: a sound money column lands
|
|
268
|
+
* on TWO right edges (the typed one, the computed one) — a broken one lands
|
|
269
|
+
* on as many as it has rows, and the reader can no longer add it up by eye. */
|
|
270
|
+
align?: "left" | "right";
|
|
259
271
|
placeholder?: string | ReactNode;
|
|
260
272
|
struck?: boolean;
|
|
261
273
|
tone?: TextColor;
|
|
262
274
|
trailing?: ReactNode;
|
|
263
275
|
numberOfLines?: number;
|
|
264
276
|
}) {
|
|
265
|
-
const { display, placeholder, struck, tone, trailing, numberOfLines } = o;
|
|
277
|
+
const { display, placeholder, struck, tone, trailing, numberOfLines, align } = o;
|
|
266
278
|
const isEmptyString = typeof display === "string" && display === "";
|
|
267
279
|
return (
|
|
268
280
|
<>
|
|
269
281
|
{isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
|
|
270
282
|
<View style={styles.viewNode}>{placeholder}</View>
|
|
271
283
|
) : typeof display === "string" ? (
|
|
272
|
-
<Text numberOfLines={numberOfLines ?? 1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
|
|
284
|
+
<Text numberOfLines={numberOfLines ?? 1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null, align === "right" ? styles.viewRight : null]}>
|
|
273
285
|
{display || (typeof placeholder === "string" ? placeholder : "") || "—"}
|
|
274
286
|
</Text>
|
|
275
287
|
) : (
|
|
@@ -293,10 +305,10 @@ function fieldContent(o: {
|
|
|
293
305
|
* ARRIVE (loud on its own), a `framed` field already has one, so it takes the
|
|
294
306
|
* tint as well.
|
|
295
307
|
*/
|
|
296
|
-
function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; active?: boolean; editing?: boolean }) {
|
|
308
|
+
function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; inert?: boolean; active?: boolean; editing?: boolean }) {
|
|
297
309
|
return (hovered: boolean): StyleProp<ViewStyle>[] => [
|
|
298
310
|
styles.view,
|
|
299
|
-
(o.variant === "bare" || o.disabled) && styles.viewBare,
|
|
311
|
+
(o.variant === "bare" || o.disabled || o.inert) && styles.viewBare,
|
|
300
312
|
o.disabled === true && styles.viewInert,
|
|
301
313
|
// Keyed on the VARIANT alone, never on `disabled` — a disabled `framed`
|
|
302
314
|
// field shares the line above (it must not promise a press) but stays in its
|
|
@@ -305,7 +317,7 @@ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; activ
|
|
|
305
317
|
o.variant === "bare" && styles.viewBareBleed,
|
|
306
318
|
CONTROL_TRANSITION,
|
|
307
319
|
o.active && styles.viewActive,
|
|
308
|
-
!o.active && hovered && !o.disabled && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
|
|
320
|
+
!o.active && hovered && !o.disabled && !o.inert && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
|
|
309
321
|
// While an input is mounted inside, it brings its own height — the surface
|
|
310
322
|
// must not add its resting vertical padding on top.
|
|
311
323
|
o.editing && styles.editHost,
|
|
@@ -373,9 +385,30 @@ interface InlineEditViewProps {
|
|
|
373
385
|
* rendered as-is — e.g. a `MemberChip` / `OptionBadge` for a rich resting
|
|
374
386
|
* value. A node takes the flex slot; the trailing adornment still right-aligns. */
|
|
375
387
|
display: string | ReactNode;
|
|
388
|
+
/** Which edge the RESTING value sits on. Money in a column takes "right", so
|
|
389
|
+
* a typed figure shares its edge with the derived one beside it; anything
|
|
390
|
+
* else keeps the default. reviewing.md probe 6: a sound money column lands
|
|
391
|
+
* on TWO right edges (the typed one, the computed one) — a broken one lands
|
|
392
|
+
* on as many as it has rows, and the reader can no longer add it up by eye. */
|
|
393
|
+
align?: "left" | "right";
|
|
376
394
|
/** Empty-state content. A string reads as muted placeholder text; a NODE (a dashed
|
|
377
395
|
* avatar "add" ghost, a placeholder chip) renders as-is in the value slot. */
|
|
378
396
|
placeholder?: string | ReactNode;
|
|
397
|
+
/**
|
|
398
|
+
* Wear the DISABLED surface — flat, borderless, no hover, no trailing
|
|
399
|
+
* adornment — while staying pressable.
|
|
400
|
+
*
|
|
401
|
+
* The state `disabled` cannot express: a value nothing can CHANGE but anything
|
|
402
|
+
* can still OPEN. A reference on a closed record is the case — its link is
|
|
403
|
+
* fixed and its facts frozen, yet reading who it points at is ordinary. Marked
|
|
404
|
+
* `disabled` it would refuse the press that shows them; left framed it would
|
|
405
|
+
* be the one field on a read-only surface still advertising an edit.
|
|
406
|
+
*
|
|
407
|
+
* Like `disabled` and unlike `variant="bare"`, it does NOT take the −8px
|
|
408
|
+
* bleed: the field stays in the column its editable neighbours sit in, which
|
|
409
|
+
* is the whole reason a read-only surface reads as level.
|
|
410
|
+
*/
|
|
411
|
+
inert?: boolean;
|
|
379
412
|
onPress?: () => void;
|
|
380
413
|
disabled?: boolean;
|
|
381
414
|
accessibilityLabel?: string;
|
|
@@ -402,7 +435,15 @@ interface InlineEditViewProps {
|
|
|
402
435
|
anchorRef?: Ref<View>;
|
|
403
436
|
/** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving.
|
|
404
437
|
* DECORATION only: it renders inside the press target, so it may never hold a
|
|
405
|
-
* control (a button must not contain a button). A control goes in `actions`.
|
|
438
|
+
* control (a button must not contain a button). A control goes in `actions`.
|
|
439
|
+
*
|
|
440
|
+
* **Suppressed while `disabled`.** The adornment is the field's PROMISE that
|
|
441
|
+
* something opens — a chevron says "a list is behind this", a calendar glyph
|
|
442
|
+
* says "a picker is". A disabled field keeps neither promise, and it has
|
|
443
|
+
* already given up the frame and the hover wash, so the glyph is then the only
|
|
444
|
+
* mark left saying "control" — which makes a read-only value read as an
|
|
445
|
+
* editable one that ignores the click. Dropping it is what leaves plain text,
|
|
446
|
+
* which is what a value you cannot change is. */
|
|
406
447
|
trailing?: ReactNode;
|
|
407
448
|
/** Verbs that live ON the field's surface — an `InlineButton` Copy, Open… Use
|
|
408
449
|
* this when the act is ABOUT THE VALUE, so it travels with what it acts on
|
|
@@ -480,7 +521,7 @@ function multilineBox(numberOfLines: number | undefined): ViewStyle | null {
|
|
|
480
521
|
}
|
|
481
522
|
|
|
482
523
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
483
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines, role = "button" } = props;
|
|
524
|
+
const { display, align, placeholder, onPress, disabled, inert, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines, role = "button" } = props;
|
|
484
525
|
// A combobox MUST expose its expanded state; a button must not. Deriving it from
|
|
485
526
|
// the `active` the popover callers already pass keeps the two from drifting apart
|
|
486
527
|
// — there is no way to take the role without the announcement.
|
|
@@ -493,8 +534,8 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
493
534
|
onPress();
|
|
494
535
|
}
|
|
495
536
|
: undefined;
|
|
496
|
-
const content = fieldContent({ display, placeholder, struck, tone, trailing, numberOfLines });
|
|
497
|
-
const surface = fieldSurface({ variant, disabled, active });
|
|
537
|
+
const content = fieldContent({ display, align, placeholder, struck, tone, trailing: disabled || inert ? undefined : trailing, numberOfLines });
|
|
538
|
+
const surface = fieldSurface({ variant, disabled, inert, active });
|
|
498
539
|
|
|
499
540
|
// WITH VERBS: the shell owns the surface, the pressable is just the value
|
|
500
541
|
// region inside it. `ref` stays on the PRESSABLE — it means "the focusable
|
|
@@ -557,6 +598,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
557
598
|
editing,
|
|
558
599
|
editOpen,
|
|
559
600
|
display,
|
|
601
|
+
align,
|
|
560
602
|
placeholder,
|
|
561
603
|
onBegin,
|
|
562
604
|
children,
|
|
@@ -604,6 +646,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
604
646
|
<InlineEditView
|
|
605
647
|
ref={viewRef}
|
|
606
648
|
variant={variant}
|
|
649
|
+
align={align}
|
|
607
650
|
display={display}
|
|
608
651
|
placeholder={placeholder}
|
|
609
652
|
onPress={onBegin}
|
|
@@ -662,7 +705,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
662
705
|
accessibilityLabel={accessibilityLabel}
|
|
663
706
|
style={styles.viewInner}
|
|
664
707
|
>
|
|
665
|
-
{fieldContent({ display, placeholder, struck, trailing: affordance, numberOfLines })}
|
|
708
|
+
{fieldContent({ display, align, placeholder, struck, trailing: disabled ? undefined : affordance, numberOfLines })}
|
|
666
709
|
</Pressable>
|
|
667
710
|
)}
|
|
668
711
|
</InlineFieldShell>
|
|
@@ -814,6 +857,7 @@ const styles = StyleSheet.create({
|
|
|
814
857
|
borderColor: HOVER_BORDER,
|
|
815
858
|
},
|
|
816
859
|
placeholder: { color: colors.zinc[400] },
|
|
860
|
+
viewRight: { textAlign: "right" as const },
|
|
817
861
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
818
862
|
// `textAlign` is not cosmetic here: the resting box is a button-role Pressable,
|
|
819
863
|
// so the UA stylesheet centres every string inside it. A STRING display never
|
|
@@ -20,6 +20,9 @@ export interface InlineNumberInputProps {
|
|
|
20
20
|
* BOTH modes so the field never changes width on focus; pass it
|
|
21
21
|
* UNCONDITIONALLY and `disabled` the verb when it has nothing to act on. */
|
|
22
22
|
actions?: ReactNode;
|
|
23
|
+
/** Which edge the RESTING number sits on. A money column takes "right" so the
|
|
24
|
+
* typed figure shares its edge with the derived one beside it. */
|
|
25
|
+
align?: "left" | "right";
|
|
23
26
|
accessibilityLabel?: string;
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -29,7 +32,7 @@ export interface InlineNumberInputProps {
|
|
|
29
32
|
* the bare numeric field at the same height, so the form never reflows.
|
|
30
33
|
*/
|
|
31
34
|
export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
32
|
-
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
|
|
35
|
+
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel, variant, actions, align } = props;
|
|
33
36
|
const edit = useInlineEdit<number | null>({ value, onSave });
|
|
34
37
|
|
|
35
38
|
const onKeyDown = useCallback(
|
|
@@ -52,6 +55,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
52
55
|
return (
|
|
53
56
|
<InlineEditFrame
|
|
54
57
|
variant={variant}
|
|
58
|
+
align={align}
|
|
55
59
|
actions={actions}
|
|
56
60
|
editing={edit.editing}
|
|
57
61
|
display={display}
|
|
@@ -210,7 +210,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
210
210
|
// moving to one element silently dropped it: a done task on the board kept
|
|
211
211
|
// its title upright. Anything else the view used to render has to move the
|
|
212
212
|
// same way, or it goes the same way — quietly.
|
|
213
|
-
variant={variant}
|
|
213
|
+
variant={disabled ? "bare" : variant}
|
|
214
214
|
style={
|
|
215
215
|
struck
|
|
216
216
|
? { textDecorationLine: "line-through" as const, color: colors.zinc[500] }
|
package/src/line_chart.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { useMemo, useState, useCallback } from "react";
|
|
5
5
|
import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
|
|
6
6
|
import { useLoticsLocale } from "./locale";
|
|
7
|
-
import {
|
|
7
|
+
import { axisLabelIndices } from "./axis_label_indices";
|
|
8
8
|
|
|
9
9
|
export interface LineChartPoint {
|
|
10
10
|
x: string | number;
|
|
@@ -97,7 +97,7 @@ export function LineChart(props: LineChartProps) {
|
|
|
97
97
|
const visibleLabels = useMemo(() => {
|
|
98
98
|
if (data.length === 0 || chartWidth === 0) return [];
|
|
99
99
|
|
|
100
|
-
return
|
|
100
|
+
return axisLabelIndices(data.length, chartWidth).map((index) => ({
|
|
101
101
|
index,
|
|
102
102
|
label: formatXLabel(data[index].x),
|
|
103
103
|
}));
|
package/src/locale.tsx
CHANGED
|
@@ -72,6 +72,9 @@ export interface LoticsLocale {
|
|
|
72
72
|
inline: { saveError: string; save: string; cancel: string };
|
|
73
73
|
/** `ChoiceList` custom-answer placeholder + `ClarifyWizard` navigation chrome. */
|
|
74
74
|
clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
|
|
75
|
+
/** `ChargeLines`: the screen-reader names of a charge line's three money
|
|
76
|
+
* fields, each qualified by the charge it belongs to. */
|
|
77
|
+
chargeLines: { quantity: (label: string) => string; unitPrice: (label: string) => string; amount: (label: string) => string };
|
|
75
78
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
76
79
|
ledger: { rowDetails: (label: string) => string };
|
|
77
80
|
/** `RunningLedger`: the closing line's label ("Số dư hiện tại", "Tồn kho
|
|
@@ -111,6 +114,14 @@ export interface LoticsLocale {
|
|
|
111
114
|
chip: { remove: string };
|
|
112
115
|
/** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
|
|
113
116
|
sequence: { moveUp: string; moveDown: string; remove: string };
|
|
117
|
+
/** `Board` / `BoardCard`: the card's keyboard door, its move control, the
|
|
118
|
+
* destination menu's items, and a column holding nothing. */
|
|
119
|
+
board: {
|
|
120
|
+
open: (title: string) => string;
|
|
121
|
+
move: (title: string) => string;
|
|
122
|
+
moveTo: (column: string) => string;
|
|
123
|
+
empty: string;
|
|
124
|
+
};
|
|
114
125
|
/** `TrendFooter`: the direction words of the trend sentence. */
|
|
115
126
|
trendFooter: { up: string; down: string };
|
|
116
127
|
/** The countdown vocabulary — see `deadline.ts`. 0 and 1 get their own words
|
|
@@ -323,6 +334,7 @@ export const en: LoticsLocale = {
|
|
|
323
334
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
324
335
|
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
325
336
|
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
337
|
+
chargeLines: { quantity: (label) => `Quantity for ${label}`, unitPrice: (label) => `Unit price for ${label}`, amount: (label) => `Amount for ${label}` },
|
|
326
338
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
327
339
|
runningLedger: { currentBalance: "Current balance" },
|
|
328
340
|
stepper: { complete: "Complete step", progress: "Progress" },
|
|
@@ -338,6 +350,12 @@ export const en: LoticsLocale = {
|
|
|
338
350
|
errorState: { retry: "Try again" },
|
|
339
351
|
chip: { remove: "Remove" },
|
|
340
352
|
sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
|
|
353
|
+
board: {
|
|
354
|
+
open: (title) => `Open ${title}`,
|
|
355
|
+
move: (title) => `Move ${title}`,
|
|
356
|
+
moveTo: (column) => `Move to ${column}`,
|
|
357
|
+
empty: "Nothing here",
|
|
358
|
+
},
|
|
341
359
|
trendFooter: { up: "Up", down: "Down" },
|
|
342
360
|
deadline: {
|
|
343
361
|
overdue: (days) => `${days} ${days === 1 ? "day" : "days"} overdue`,
|
|
@@ -513,7 +531,7 @@ export const vi: LoticsLocale = {
|
|
|
513
531
|
ascending: " (tăng dần)",
|
|
514
532
|
descending: " (giảm dần)",
|
|
515
533
|
},
|
|
516
|
-
referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "
|
|
534
|
+
referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Hủy" },
|
|
517
535
|
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…", someSelected: "một số đã chọn" },
|
|
518
536
|
picker: { emptyOption: "Không có" },
|
|
519
537
|
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", chooseTime: "Chọn giờ", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
|
|
@@ -525,6 +543,7 @@ export const vi: LoticsLocale = {
|
|
|
525
543
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
|
526
544
|
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
527
545
|
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" },
|
|
546
|
+
chargeLines: { quantity: (label) => `Số lượng ${label}`, unitPrice: (label) => `Đơn giá ${label}`, amount: (label) => `Số tiền ${label}` },
|
|
528
547
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
529
548
|
runningLedger: { currentBalance: "Số dư hiện tại" },
|
|
530
549
|
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
@@ -540,6 +559,12 @@ export const vi: LoticsLocale = {
|
|
|
540
559
|
errorState: { retry: "Thử lại" },
|
|
541
560
|
chip: { remove: "Xóa" },
|
|
542
561
|
sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
|
|
562
|
+
board: {
|
|
563
|
+
open: (title) => `Mở ${title}`,
|
|
564
|
+
move: (title) => `Chuyển ${title}`,
|
|
565
|
+
moveTo: (column) => `Chuyển sang ${column}`,
|
|
566
|
+
empty: "Chưa có gì",
|
|
567
|
+
},
|
|
543
568
|
trendFooter: { up: "Tăng", down: "Giảm" },
|
|
544
569
|
deadline: {
|
|
545
570
|
overdue: (days) => `Quá hạn ${days} ngày`,
|
package/src/matrix.tsx
CHANGED
|
@@ -22,7 +22,6 @@ interface MatrixContextValue {
|
|
|
22
22
|
heatBase: string;
|
|
23
23
|
formatValue: (n: number) => string;
|
|
24
24
|
rowLabelWidth: number;
|
|
25
|
-
totalColWidth: number;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
/** Gap between a matrix's columns. Exported because a caller sizing its own
|
|
@@ -110,7 +109,6 @@ export function Matrix(props: MatrixProps) {
|
|
|
110
109
|
heatBase: colors[heatColor][600],
|
|
111
110
|
formatValue,
|
|
112
111
|
rowLabelWidth,
|
|
113
|
-
totalColWidth: 64,
|
|
114
112
|
};
|
|
115
113
|
|
|
116
114
|
return (
|
|
@@ -128,7 +126,7 @@ export interface MatrixHeaderProps {
|
|
|
128
126
|
}
|
|
129
127
|
|
|
130
128
|
function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
|
|
131
|
-
const { cols, rowLabelWidth,
|
|
129
|
+
const { cols, rowLabelWidth, hasTotals } = useMatrix();
|
|
132
130
|
const locale = useLoticsLocale();
|
|
133
131
|
const resolvedTotalLabel = totalLabel ?? locale.matrix.total;
|
|
134
132
|
return (
|
|
@@ -148,7 +146,7 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
|
|
|
148
146
|
</Text>
|
|
149
147
|
))}
|
|
150
148
|
{hasTotals ? (
|
|
151
|
-
<Text size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={
|
|
149
|
+
<Text size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={styles.totalCol}>
|
|
152
150
|
{resolvedTotalLabel}
|
|
153
151
|
</Text>
|
|
154
152
|
) : null}
|
|
@@ -163,7 +161,7 @@ export interface MatrixGridProps {
|
|
|
163
161
|
|
|
164
162
|
function MatrixGrid({ display = "both" }: MatrixGridProps) {
|
|
165
163
|
const ctx = useMatrix();
|
|
166
|
-
const { rows, cols, value, hasTotals, rowTotals, formatValue, rowLabelWidth
|
|
164
|
+
const { rows, cols, value, hasTotals, rowTotals, formatValue, rowLabelWidth } = ctx;
|
|
167
165
|
return (
|
|
168
166
|
<View style={styles.grid}>
|
|
169
167
|
{rows.map((r) => (
|
|
@@ -175,7 +173,7 @@ function MatrixGrid({ display = "both" }: MatrixGridProps) {
|
|
|
175
173
|
<MatrixCell key={c.key} ctx={ctx} display={display} row={r} col={c} value={value(r.key, c.key)} />
|
|
176
174
|
))}
|
|
177
175
|
{hasTotals ? (
|
|
178
|
-
<Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={
|
|
176
|
+
<Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={styles.totalCol}>
|
|
179
177
|
{formatValue(rowTotals.get(r.key) ?? 0)}
|
|
180
178
|
</Text>
|
|
181
179
|
) : null}
|
|
@@ -251,7 +249,7 @@ export interface MatrixTotalsProps {
|
|
|
251
249
|
}
|
|
252
250
|
|
|
253
251
|
function MatrixTotals({ label }: MatrixTotalsProps) {
|
|
254
|
-
const { cols, colTotals, grandTotal, formatValue, rowLabelWidth
|
|
252
|
+
const { cols, colTotals, grandTotal, formatValue, rowLabelWidth } = useMatrix();
|
|
255
253
|
const locale = useLoticsLocale();
|
|
256
254
|
return (
|
|
257
255
|
<View style={styles.totalsRow}>
|
|
@@ -263,7 +261,7 @@ function MatrixTotals({ label }: MatrixTotalsProps) {
|
|
|
263
261
|
{formatValue(colTotals.get(c.key) ?? 0)}
|
|
264
262
|
</Text>
|
|
265
263
|
))}
|
|
266
|
-
<Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={
|
|
264
|
+
<Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={styles.totalCol}>
|
|
267
265
|
{formatValue(grandTotal)}
|
|
268
266
|
</Text>
|
|
269
267
|
</View>
|
|
@@ -344,6 +342,23 @@ const styles = StyleSheet.create({
|
|
|
344
342
|
paddingRight: 2,
|
|
345
343
|
},
|
|
346
344
|
totalCol: {
|
|
345
|
+
// SIZED LIKE A DATA COLUMN, not to a fixed number. It was `width: 64`, and a
|
|
346
|
+
// total is by construction the LONGEST figure in its row — so the one number
|
|
347
|
+
// the pivot exists to state was the one guaranteed to hit the clamp first. In
|
|
348
|
+
// VND it does: `formatMoney(n, {compact:true})` gives "1,51 tỷ ₫" for a grand
|
|
349
|
+
// total whose row cells read "231 tr ₫", and at 64px the grand total rendered
|
|
350
|
+
// "1,51 t…" while every cell above it fit. Nothing in the type system or a
|
|
351
|
+
// snapshot sees that — the string is whole in the DOM — so it ships.
|
|
352
|
+
//
|
|
353
|
+
// A per-glyph width estimate would be the wrong repair (a font decides glyph
|
|
354
|
+
// widths, so the arithmetic is right only at the size it was measured at).
|
|
355
|
+
// The structural answer is that a total is the same KIND of figure as a cell
|
|
356
|
+
// at the same type size, so it takes the same sizing law: one flex share of
|
|
357
|
+
// the leftover, floored. The floor is the old fixed width, so a matrix with
|
|
358
|
+
// many columns in a narrow frame never gets a total column narrower than it
|
|
359
|
+
// used to have.
|
|
360
|
+
flex: 1,
|
|
361
|
+
minWidth: 64,
|
|
347
362
|
paddingLeft: 4,
|
|
348
363
|
},
|
|
349
364
|
totalsRow: {
|
package/src/modal.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { PortalHost } from "@lotics/ui/portal";
|
|
|
7
7
|
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
8
8
|
import { useLoticsLocale } from "@lotics/ui/locale";
|
|
9
9
|
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
10
|
+
import { useScrollSeam } from "./use_scroll_seam";
|
|
10
11
|
|
|
11
12
|
export interface ModalProps {
|
|
12
13
|
open: boolean;
|
|
@@ -34,8 +35,17 @@ export function Modal(props: ModalProps) {
|
|
|
34
35
|
const { open, onClose, children, testID } = props;
|
|
35
36
|
useOverlayScope(open);
|
|
36
37
|
|
|
38
|
+
// Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
|
|
39
|
+
// `Modal`'s body-level div on first render and never re-orders it, so an
|
|
40
|
+
// always-mounted overlay claims its slot ahead of one opened later and covers
|
|
41
|
+
// it. Mounting on open makes DOM order open order. `animationType` still
|
|
42
|
+
// FADES IN (the element mounts already visible and the animation runs on the
|
|
43
|
+
// first paint); the exit is immediate, exactly as `FileGalleryModal`'s
|
|
44
|
+
// full-screen takeover has always closed.
|
|
45
|
+
if (!open) return null;
|
|
46
|
+
|
|
37
47
|
return (
|
|
38
|
-
<RNModal visible
|
|
48
|
+
<RNModal visible onRequestClose={onClose} animationType="fade">
|
|
39
49
|
<View testID={testID} style={styles.surface}>
|
|
40
50
|
<PortalHost>{children}</PortalHost>
|
|
41
51
|
</View>
|
|
@@ -93,6 +103,13 @@ export function ModalHeader(props: ModalHeaderProps) {
|
|
|
93
103
|
|
|
94
104
|
export interface ModalBodyProps {
|
|
95
105
|
children: ReactNode;
|
|
106
|
+
/**
|
|
107
|
+
* The IDENTITY of the content in the scroller, for a takeover that SWAPS its
|
|
108
|
+
* body in place — a wizard stepping between steps, a console changing subject.
|
|
109
|
+
* Changing it opens the new content at the top and restores the previous
|
|
110
|
+
* content's offset when the key comes back. See `DrawerScrollArea`.
|
|
111
|
+
*/
|
|
112
|
+
scrollKey?: string;
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
/**
|
|
@@ -100,14 +117,17 @@ export interface ModalBodyProps {
|
|
|
100
117
|
* footer. Fills the remaining height; its content scrolls.
|
|
101
118
|
*/
|
|
102
119
|
export function ModalBody(props: ModalBodyProps) {
|
|
103
|
-
const { children } = props;
|
|
120
|
+
const { children, scrollKey } = props;
|
|
121
|
+
const seam = useScrollSeam(scrollKey);
|
|
104
122
|
// A takeover is still a panel: `ModalHeader`'s title is `lg`, so a section
|
|
105
123
|
// heading inside the body takes the ramp's `####` rung. Same rule as the
|
|
106
124
|
// drawer and the dialog, published from the same kind of region — the one
|
|
107
125
|
// that owns the surface's padding. See `heading_altitude.ts`.
|
|
108
126
|
return (
|
|
109
127
|
<HeadingAltitudeContext.Provider value="panel">
|
|
110
|
-
<ScrollView contentContainerStyle={styles.bodyContent}>
|
|
128
|
+
<ScrollView {...seam} contentContainerStyle={styles.bodyContent}>
|
|
129
|
+
{children}
|
|
130
|
+
</ScrollView>
|
|
111
131
|
</HeadingAltitudeContext.Provider>
|
|
112
132
|
);
|
|
113
133
|
}
|