@lotics/ui 41.0.1 → 41.3.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 +4 -4
- package/MIGRATION.md +106 -0
- package/docs/ai_patterns.md +36 -0
- package/docs/catalog.md +70 -12
- package/docs/composition.md +89 -11
- package/docs/data_entry.md +107 -1
- package/docs/templates.md +120 -9
- package/examples/tpl_item_list.tsx +21 -0
- package/examples/tpl_record.tsx +1006 -45
- package/package.json +4 -1
- package/src/file_rows.tsx +13 -3
- package/src/inline_files.tsx +219 -0
- package/src/inline_text_input.tsx +33 -2
- package/src/markdown.css +43 -0
- package/src/markdown.tsx +7 -1
- package/src/markdown.web.tsx +4 -2
- package/src/markdown_types.ts +21 -0
- package/src/member_chip.tsx +12 -2
- package/src/text_disclosure.tsx +70 -0
- package/src/text_link.tsx +13 -3
- package/src/timeline.tsx +194 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "41.0
|
|
3
|
+
"version": "41.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -109,6 +109,7 @@
|
|
|
109
109
|
"react-native": "./src/markdown.tsx",
|
|
110
110
|
"default": "./src/markdown.web.tsx"
|
|
111
111
|
},
|
|
112
|
+
"./markdown_types": "./src/markdown_types.ts",
|
|
112
113
|
"./markdown.css": "./src/markdown.css",
|
|
113
114
|
"./confidence": "./src/confidence.tsx",
|
|
114
115
|
"./finding": "./src/finding.tsx",
|
|
@@ -153,6 +154,7 @@
|
|
|
153
154
|
"./inline_edit": "./src/inline_edit.tsx",
|
|
154
155
|
"./inline_slot": "./src/inline_slot.tsx",
|
|
155
156
|
"./inline_static": "./src/inline_static.tsx",
|
|
157
|
+
"./inline_files": "./src/inline_files.tsx",
|
|
156
158
|
"./inline_text_input": "./src/inline_text_input.tsx",
|
|
157
159
|
"./inline_number_input": "./src/inline_number_input.tsx",
|
|
158
160
|
"./inline_select": "./src/inline_select.tsx",
|
|
@@ -193,6 +195,7 @@
|
|
|
193
195
|
"./counter": "./src/counter.tsx",
|
|
194
196
|
"./link": "./src/link.tsx",
|
|
195
197
|
"./reference_field": "./src/reference_field.tsx",
|
|
198
|
+
"./text_disclosure": "./src/text_disclosure.tsx",
|
|
196
199
|
"./text_link": "./src/text_link.tsx",
|
|
197
200
|
"./sort_header": "./src/sort_header.tsx",
|
|
198
201
|
"./skeleton": "./src/skeleton.tsx",
|
package/src/file_rows.tsx
CHANGED
|
@@ -10,7 +10,6 @@ import { Alert } from "./alert";
|
|
|
10
10
|
import { FileRow } from "./file_row";
|
|
11
11
|
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
12
12
|
import { FileGalleryModal } from "./file_gallery_modal";
|
|
13
|
-
import { resolveMime } from "./file_badge";
|
|
14
13
|
import { downloadFileFromUrl } from "./download";
|
|
15
14
|
import { type GalleryLabels } from "./file_preview_types";
|
|
16
15
|
import { useLoticsLocale } from "./locale";
|
|
@@ -18,7 +17,18 @@ import type { DisplayFile } from "./file_thumbnail";
|
|
|
18
17
|
|
|
19
18
|
export interface FileRowsProps {
|
|
20
19
|
files: DisplayFile[];
|
|
21
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Secondary line per file. Default: NONE.
|
|
22
|
+
*
|
|
23
|
+
* It defaulted to the file-type label, which every row already states twice —
|
|
24
|
+
* the `FileBadge` encodes the type as its colour AND prints it inside the
|
|
25
|
+
* glyph, and the filename ends in the extension. A fourth statement of one
|
|
26
|
+
* fact is not a caption, it is noise, and it cost every consumer a line of
|
|
27
|
+
* row height to say nothing.
|
|
28
|
+
*
|
|
29
|
+
* Pass something the NAME does not already carry — what an invoice covers, who
|
|
30
|
+
* uploaded it, when it landed.
|
|
31
|
+
*/
|
|
22
32
|
meta?: (file: DisplayFile) => string | undefined;
|
|
23
33
|
/** Adds a (confirmed) "Remove" to each ⋯ menu and the gallery toolbar. The host
|
|
24
34
|
* drops the file from its own state. */
|
|
@@ -84,7 +94,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
|
|
|
84
94
|
<FileRow
|
|
85
95
|
key={file.id}
|
|
86
96
|
name={file.filename}
|
|
87
|
-
meta={meta ? meta(file) :
|
|
97
|
+
meta={meta ? meta(file) : undefined}
|
|
88
98
|
mimeType={file.mimeType}
|
|
89
99
|
onPress={() => setActiveIndex(index)}
|
|
90
100
|
trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { FileRows } from "./file_rows";
|
|
6
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
7
|
+
import { pickFiles } from "./file_picker";
|
|
8
|
+
import { type DisplayFile } from "./file_thumbnail";
|
|
9
|
+
import { type GalleryLabels } from "./file_preview_types";
|
|
10
|
+
import { useLoticsLocale } from "./locale";
|
|
11
|
+
|
|
12
|
+
export interface InlineFilesProps {
|
|
13
|
+
/** What is attached now. */
|
|
14
|
+
files: DisplayFile[];
|
|
15
|
+
/**
|
|
16
|
+
* The user picked these. The HOST uploads them and persists the result — this
|
|
17
|
+
* kit never talks to a server, so `files` only changes when the host says so.
|
|
18
|
+
*
|
|
19
|
+
* **`multiple` decides what the host must DO with them**: appending on a
|
|
20
|
+
* single-file field is how a field that holds one contract quietly ends up
|
|
21
|
+
* holding three. See that prop.
|
|
22
|
+
*
|
|
23
|
+
* Return a promise and the CTA stays busy until it settles, which is the whole
|
|
24
|
+
* of this component's loading state: a picked file is in flight for as long as
|
|
25
|
+
* the host takes, and a control that re-arms immediately invites the second
|
|
26
|
+
* press that files it twice.
|
|
27
|
+
*
|
|
28
|
+
* Omit for a read-only field — the same list, no CTA.
|
|
29
|
+
*/
|
|
30
|
+
onAdd?: (picked: File[]) => void | Promise<void>;
|
|
31
|
+
/** Adds a (confirmed) Remove to each row's ⋯ menu. Omit for add-only. */
|
|
32
|
+
onRemove?: (file: DisplayFile) => void;
|
|
33
|
+
/** Open outside the app — a new tab on the frontend, `openExternal` in a
|
|
34
|
+
* sandboxed app. Omit and the row still previews in place. */
|
|
35
|
+
onOpenExternal?: (file: DisplayFile) => void;
|
|
36
|
+
/** Override the download (the host frontend sends `credentials`). */
|
|
37
|
+
onDownload?: (file: DisplayFile) => void;
|
|
38
|
+
/**
|
|
39
|
+
* **The FIELD's cardinality, not the picker's convenience.** Default `true`.
|
|
40
|
+
*
|
|
41
|
+
* `true` — a set. The picker takes several at once, and the CTA reads
|
|
42
|
+
* `addMoreLabel` once something is attached, because adding a second page to a
|
|
43
|
+
* document set is an ordinary act.
|
|
44
|
+
*
|
|
45
|
+
* `false` — exactly one. The picker takes one, and the CTA reads
|
|
46
|
+
* `replaceLabel`, because "add another" is not a thing a single-file field can
|
|
47
|
+
* do: the honest verb is replace, and offering "add" there produces a field
|
|
48
|
+
* holding two of something the schema says there is one of. **The host must
|
|
49
|
+
* then SET rather than append in `onAdd`** — the component cannot enforce it
|
|
50
|
+
* (it does not own the data), and the failure is silent, so the call site
|
|
51
|
+
* reads `onAdd={([f]) => setFile(f)}` against `onAdd={(fs) => setFiles([...files, ...fs])}`
|
|
52
|
+
* and the difference is visible where it is decided.
|
|
53
|
+
*
|
|
54
|
+
* This is a fact about the field, which is why it is a prop and the LIST shape
|
|
55
|
+
* is not: one file or many, the reader still identifies a document by its name.
|
|
56
|
+
*/
|
|
57
|
+
multiple?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* CTA words for the three states — nothing attached, adding to a set,
|
|
60
|
+
* replacing the one.
|
|
61
|
+
*
|
|
62
|
+
* Three, because they are three different acts and a generic "Add files"
|
|
63
|
+
* misleads in two of them. Name them for the domain ("Attach original" /
|
|
64
|
+
* "Add page" / "Replace contract") and the field stops needing a caption to
|
|
65
|
+
* explain itself. Each falls back to the one before it, so a single-file field
|
|
66
|
+
* that never says `replaceLabel` still reads sensibly.
|
|
67
|
+
*/
|
|
68
|
+
addLabel?: string;
|
|
69
|
+
addMoreLabel?: string;
|
|
70
|
+
replaceLabel?: string;
|
|
71
|
+
/** Replaces the CTA's words while `onAdd` is in flight. Omit and the CTA keeps
|
|
72
|
+
* its words and simply disables — no invented English in a localized app. */
|
|
73
|
+
busyLabel?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Renders this sentence INSTEAD of the CTA — for a field with nothing to
|
|
76
|
+
* attach TO yet (a scan whose invoice does not exist; a line before its order
|
|
77
|
+
* is saved).
|
|
78
|
+
*
|
|
79
|
+
* Deliberately not a disabled button. A disabled control states that you may
|
|
80
|
+
* not, and leaves the reader to work out why and what would change it; a
|
|
81
|
+
* sentence states the act that unblocks it. Same pixels, one of them useful.
|
|
82
|
+
*/
|
|
83
|
+
blockedReason?: string;
|
|
84
|
+
/** Shown when nothing is attached AND there is no CTA. Default "—". */
|
|
85
|
+
emptyLabel?: string;
|
|
86
|
+
/** Native accept filter, e.g. `"application/pdf,image/*"`. */
|
|
87
|
+
accept?: string;
|
|
88
|
+
/** Translated gallery/menu chrome, forwarded to `FileRows`. */
|
|
89
|
+
labels?: Partial<GalleryLabels>;
|
|
90
|
+
/** Credentials mode for the preview fetches — `"include"` for auth-gated
|
|
91
|
+
* proxy URLs, omitted for an app's presigned URLs. */
|
|
92
|
+
credentials?: RequestCredentials;
|
|
93
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The FILES member of the `Inline*` family — a record's attachments, editable in
|
|
98
|
+
* the value column of a `DetailRow`.
|
|
99
|
+
*
|
|
100
|
+
* Every other field type had both halves already (`Select`/`InlineSelect`,
|
|
101
|
+
* `TextInputField`/`InlineTextInput`); files had only the section-scale one
|
|
102
|
+
* (`FilesEditor`), so a record FIELD holding documents was hand-rolled per app as
|
|
103
|
+
* `FileRows` + a picker + a button. Each copy re-derived the same decisions —
|
|
104
|
+
* including the alignment one, which has a counter-intuitive answer: this field
|
|
105
|
+
* sits on the inline grid's VERTICAL beat but takes NO horizontal inset, because
|
|
106
|
+
* its children are boxes rather than text (see the comment on the wrapper).
|
|
107
|
+
*
|
|
108
|
+
* A LIST, not a grid, and not switchable: documents are identified by NAME — a
|
|
109
|
+
* PDF/Word/Excel thumbnail is a grey page identical to every other grey page — so
|
|
110
|
+
* a grid of them is a wall of one tile under a truncated caption. Images are
|
|
111
|
+
* identified by their CONTENT and want `FileGrid` / `FilesEditor` instead. Which
|
|
112
|
+
* one a field holds is a judgment about the DATA, so it picks the component, not
|
|
113
|
+
* a prop on this one.
|
|
114
|
+
*
|
|
115
|
+
* Lean on purpose: no selection mode, no bulk bar, no upload queue. It shows,
|
|
116
|
+
* previews, adds and removes. Reach for `FilesEditor` when a surface genuinely
|
|
117
|
+
* needs multi-select and bulk download — a record row does not.
|
|
118
|
+
*/
|
|
119
|
+
export function InlineFiles(props: InlineFilesProps) {
|
|
120
|
+
const {
|
|
121
|
+
files,
|
|
122
|
+
onAdd,
|
|
123
|
+
onRemove,
|
|
124
|
+
onOpenExternal,
|
|
125
|
+
onDownload,
|
|
126
|
+
multiple = true,
|
|
127
|
+
addLabel,
|
|
128
|
+
addMoreLabel,
|
|
129
|
+
replaceLabel,
|
|
130
|
+
busyLabel,
|
|
131
|
+
blockedReason,
|
|
132
|
+
emptyLabel = "—",
|
|
133
|
+
accept,
|
|
134
|
+
labels,
|
|
135
|
+
credentials,
|
|
136
|
+
onError,
|
|
137
|
+
} = props;
|
|
138
|
+
|
|
139
|
+
const loc = useLoticsLocale().filesEditor;
|
|
140
|
+
const [busy, setBusy] = useState(false);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Single-flight, released in `finally`.
|
|
144
|
+
*
|
|
145
|
+
* The picker is INSIDE the guard, not around it: a second press while the
|
|
146
|
+
* dialog is open would open a second dialog, and both would resolve into
|
|
147
|
+
* `onAdd`. Cancelling still settles (`pickFiles` resolves `[]`), so the guard
|
|
148
|
+
* releases without the host being called.
|
|
149
|
+
*/
|
|
150
|
+
const add = useCallback(async () => {
|
|
151
|
+
if (busy || onAdd === undefined) return;
|
|
152
|
+
setBusy(true);
|
|
153
|
+
try {
|
|
154
|
+
const picked = await pickFiles({ accept, multiple });
|
|
155
|
+
if (picked.length === 0) return;
|
|
156
|
+
await onAdd(picked);
|
|
157
|
+
} finally {
|
|
158
|
+
setBusy(false);
|
|
159
|
+
}
|
|
160
|
+
}, [busy, onAdd, accept, multiple]);
|
|
161
|
+
|
|
162
|
+
const base = addLabel ?? loc.upload;
|
|
163
|
+
const cta =
|
|
164
|
+
files.length === 0
|
|
165
|
+
? base
|
|
166
|
+
: multiple
|
|
167
|
+
? (addMoreLabel ?? base)
|
|
168
|
+
: (replaceLabel ?? base);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
/* The inline grid's VERTICAL contract only — deliberately not `InlineSlot`.
|
|
172
|
+
*
|
|
173
|
+
* That component pads 8px + a 1px border so a raw STRING's baseline lands
|
|
174
|
+
* where an editor's text does, which is right for text and wrong here: these
|
|
175
|
+
* children are BOXES (file rows, a button), and a box aligns with the
|
|
176
|
+
* editors' box, not with their text. Measured, `InlineSlot` pushed this
|
|
177
|
+
* field 9px right of every input above it — one value column, two left
|
|
178
|
+
* edges, which is the exact defect the slot exists to prevent, arriving
|
|
179
|
+
* through the slot itself.
|
|
180
|
+
*
|
|
181
|
+
* The LIST fills the column and the CTA does not, because they are different
|
|
182
|
+
* kinds of thing: a file row is this field's VALUE and every other value in
|
|
183
|
+
* the column (a text input, a select) spans it, so a short row would read as
|
|
184
|
+
* a different column; the button is an ACTION, and a full-bleed one reads as
|
|
185
|
+
* the section's rather than this field's. Hence stretch by default, with the
|
|
186
|
+
* CTA opting out via `alignSelf` rather than the container shrinking both.
|
|
187
|
+
*/
|
|
188
|
+
<View style={{ minHeight: INLINE_CONTROL_HEIGHT, justifyContent: "center" }}>
|
|
189
|
+
<View style={{ gap: 8, width: "100%" }}>
|
|
190
|
+
{files.length > 0 ? (
|
|
191
|
+
<FileRows
|
|
192
|
+
files={files}
|
|
193
|
+
onRemove={onRemove}
|
|
194
|
+
onOpenExternal={onOpenExternal}
|
|
195
|
+
onDownload={onDownload}
|
|
196
|
+
onError={onError}
|
|
197
|
+
labels={labels}
|
|
198
|
+
credentials={credentials}
|
|
199
|
+
/>
|
|
200
|
+
) : null}
|
|
201
|
+
{blockedReason !== undefined ? (
|
|
202
|
+
<Text size="sm" color="muted">{blockedReason}</Text>
|
|
203
|
+
) : onAdd !== undefined ? (
|
|
204
|
+
<View style={{ alignSelf: "flex-start" }}>
|
|
205
|
+
<Button
|
|
206
|
+
title={busy ? (busyLabel ?? cta) : cta}
|
|
207
|
+
icon="paperclip"
|
|
208
|
+
color="secondary"
|
|
209
|
+
disabled={busy}
|
|
210
|
+
onPress={() => void add()}
|
|
211
|
+
/>
|
|
212
|
+
</View>
|
|
213
|
+
) : files.length === 0 ? (
|
|
214
|
+
<Text size="sm" color="muted">{emptyLabel}</Text>
|
|
215
|
+
) : null}
|
|
216
|
+
</View>
|
|
217
|
+
</View>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -25,8 +25,37 @@ export interface InlineTextInputProps {
|
|
|
25
25
|
* a payment term, an address, a clause. Default 1 (the single-line field).
|
|
26
26
|
* Above 1 the field is that many lines tall and wraps, in both states. Enter
|
|
27
27
|
* then inserts a newline; the field commits on blur (or the ✓ in "buttons").
|
|
28
|
+
*
|
|
29
|
+
* With `autoGrow` this becomes the MINIMUM rather than the whole budget.
|
|
28
30
|
*/
|
|
29
31
|
numberOfLines?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Grow to fit the value instead of clipping it at `numberOfLines`.
|
|
34
|
+
*
|
|
35
|
+
* A fixed budget is right for a value with a KNOWN shape — a two-line address,
|
|
36
|
+
* a payment term — where reserving the space keeps the page still. It is wrong
|
|
37
|
+
* for open prose, and it fails in the worst way: the field renders a box the
|
|
38
|
+
* value does not fit, with no ellipsis, no scrollbar and no scroll, so the
|
|
39
|
+
* reader is given no evidence that anything is missing. Measured on a record
|
|
40
|
+
* note, 285 characters drew 76px of a 116px value — two lines gone, silently.
|
|
41
|
+
*
|
|
42
|
+
* Growing costs nothing this control was protecting: the field is ONE input in
|
|
43
|
+
* both states, so a grown box is the same height resting and editing, and
|
|
44
|
+
* nothing moves on focus. It grows as you type, which is the same promise the
|
|
45
|
+
* value's own wrapping already makes.
|
|
46
|
+
*
|
|
47
|
+
* Reach for it wherever the length is the AUTHOR's choice rather than the
|
|
48
|
+
* field's. Keep the fixed budget in the two cases it is still right:
|
|
49
|
+
*
|
|
50
|
+
* - **A value that can be arbitrarily long.** Growth has no ceiling — measured,
|
|
51
|
+
* 406 characters is already 238px — so a field that may hold a document wants
|
|
52
|
+
* the reserve, and a length that overflows it is the signal that the value
|
|
53
|
+
* belongs somewhere else.
|
|
54
|
+
* - **A GRID cell** (`variant="bare"` in a `DataGrid`). A cell that varies with
|
|
55
|
+
* its data breaks the row rhythm the grid promises — see the register laws in
|
|
56
|
+
* composition.md. Grow on a record surface, reserve in a table.
|
|
57
|
+
*/
|
|
58
|
+
autoGrow?: boolean;
|
|
30
59
|
accessibilityLabel?: string;
|
|
31
60
|
/** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
|
|
32
61
|
* quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
|
|
@@ -46,8 +75,9 @@ export interface InlineTextInputProps {
|
|
|
46
75
|
* implementation detail.
|
|
47
76
|
*/
|
|
48
77
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
49
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines } = props;
|
|
50
|
-
|
|
78
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines, autoGrow } = props;
|
|
79
|
+
// Growing implies wrapping: a field that grows on one line has nowhere to go.
|
|
80
|
+
const multiline = (numberOfLines ?? 1) > 1 || autoGrow === true;
|
|
51
81
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
52
82
|
|
|
53
83
|
const onKeyPress = useCallback(
|
|
@@ -128,6 +158,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
128
158
|
onKeyPress={onKeyPress}
|
|
129
159
|
multiline={multiline}
|
|
130
160
|
numberOfLines={numberOfLines}
|
|
161
|
+
autoGrow={autoGrow}
|
|
131
162
|
placeholder={placeholder}
|
|
132
163
|
accessibilityLabel={accessibilityLabel}
|
|
133
164
|
// `disabled`, NOT `editable`: TextInputField derives editability as
|
package/src/markdown.css
CHANGED
|
@@ -60,6 +60,49 @@
|
|
|
60
60
|
margin: 0;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/*
|
|
64
|
+
* EMBEDDED — markdown that is a VALUE on someone else's page, not the page.
|
|
65
|
+
*
|
|
66
|
+
* The scale above is a document's: `h1` is 24/600, which is exactly a
|
|
67
|
+
* `SectionHeadingTitle`. That is right when the markdown IS the surface (a
|
|
68
|
+
* knowledge doc, a terms page) and wrong the moment it is a field on a record,
|
|
69
|
+
* because the author of the text is then not the author of the page — and
|
|
70
|
+
* increasingly is not a person at all. A model asked for a call summary writes
|
|
71
|
+
* `##` headings and liberal `**bold**`, and the page hands them a rung one step
|
|
72
|
+
* under its own section headings: measured on a real interaction log, a machine
|
|
73
|
+
* summary inside a feed row drew 18px/600 headings over 44 medium-weight runs,
|
|
74
|
+
* so the least-trustworthy content on the screen was also the loudest.
|
|
75
|
+
*
|
|
76
|
+
* Embedded, every heading collapses to ONE treatment at the body's own size,
|
|
77
|
+
* one weight step above `strong`. A heading is still legible as a heading —
|
|
78
|
+
* it owns its line and carries the space around it, which is SHAPE rather than
|
|
79
|
+
* scale — and it can no longer outrank the surface it was dropped into.
|
|
80
|
+
*/
|
|
81
|
+
.ui-markdown-embedded h1,
|
|
82
|
+
.ui-markdown-embedded h2,
|
|
83
|
+
.ui-markdown-embedded h3,
|
|
84
|
+
.ui-markdown-embedded h4,
|
|
85
|
+
.ui-markdown-embedded h5,
|
|
86
|
+
.ui-markdown-embedded h6 {
|
|
87
|
+
font-family: Inter_600SemiBold, "apple-system", "BlinkMacSystemFont", "Segoe UI",
|
|
88
|
+
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
|
89
|
+
"Helvetica Neue", sans-serif;
|
|
90
|
+
font-size: 14px;
|
|
91
|
+
line-height: 20px;
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
padding-top: 8px;
|
|
94
|
+
padding-bottom: 2px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* A value never opens with a gap its container did not ask for. */
|
|
98
|
+
.ui-markdown-embedded > :first-child {
|
|
99
|
+
padding-top: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ui-markdown-embedded > :last-child {
|
|
103
|
+
padding-bottom: 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
/* Block elements */
|
|
64
107
|
|
|
65
108
|
.ui-markdown p {
|
package/src/markdown.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { View } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
+
import type { MarkdownProps } from "./markdown_types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Native fallback. The rich renderer (`markdown.web.tsx`) is react-markdown over
|
|
@@ -7,11 +8,16 @@ import { Text } from "./text";
|
|
|
7
8
|
* native, so this renders the source as plain text. Rich markdown shows on web
|
|
8
9
|
* (custom-code apps + the frontend on web), which is where it's used; the web
|
|
9
10
|
* variant is picked via the conditional `exports` in package.json.
|
|
11
|
+
*
|
|
12
|
+
* `variant` is accepted and ignored: plain text has one scale, so there is no
|
|
13
|
+
* heading to demote. The props type is NOT re-exported — import it from
|
|
14
|
+
* `@lotics/ui/markdown_types`, the same way `MediaPlayerProps` is reached.
|
|
10
15
|
*/
|
|
11
|
-
export function Markdown({ children }:
|
|
16
|
+
export function Markdown({ children }: MarkdownProps) {
|
|
12
17
|
return (
|
|
13
18
|
<View>
|
|
14
19
|
<Text size="sm">{children}</Text>
|
|
15
20
|
</View>
|
|
16
21
|
);
|
|
17
22
|
}
|
|
23
|
+
|
package/src/markdown.web.tsx
CHANGED
|
@@ -5,11 +5,12 @@ import ReactMarkdown from "react-markdown";
|
|
|
5
5
|
import { remarkGfmSafe } from "./remark_gfm_safe";
|
|
6
6
|
import { Icon } from "./icon";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
|
+
import type { MarkdownProps } from "./markdown_types";
|
|
8
9
|
|
|
9
|
-
export function Markdown({ children
|
|
10
|
+
export function Markdown({ children, variant = "document" }: MarkdownProps) {
|
|
10
11
|
return (
|
|
11
12
|
<View>
|
|
12
|
-
<div className="ui-markdown">
|
|
13
|
+
<div className={variant === "embedded" ? "ui-markdown ui-markdown-embedded" : "ui-markdown"}>
|
|
13
14
|
<ReactMarkdown remarkPlugins={[remarkGfmSafe]} components={markdownComponents}>
|
|
14
15
|
{children}
|
|
15
16
|
</ReactMarkdown>
|
|
@@ -18,6 +19,7 @@ export function Markdown({ children }: { children: string }) {
|
|
|
18
19
|
);
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
|
|
21
23
|
const markdownComponents = {
|
|
22
24
|
table: CopyableTable,
|
|
23
25
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface MarkdownProps {
|
|
2
|
+
children: string;
|
|
3
|
+
/**
|
|
4
|
+
* Whose type ladder the headings belong to.
|
|
5
|
+
*
|
|
6
|
+
* **`"document"` (default)** — the markdown IS the surface: a knowledge doc, a
|
|
7
|
+
* terms page, a rendered artifact. Its `h1` is the page's `h1`.
|
|
8
|
+
*
|
|
9
|
+
* **`"embedded"`** — the markdown is a VALUE on a page someone else designed:
|
|
10
|
+
* a field on a record, a summary in a feed row, a note in a drawer. Every
|
|
11
|
+
* heading collapses to the body's own size so it cannot outrank the section
|
|
12
|
+
* that contains it.
|
|
13
|
+
*
|
|
14
|
+
* **Reach for `"embedded"` whenever the text was not written by whoever built
|
|
15
|
+
* the screen** — and that is now the common case, because the writer is
|
|
16
|
+
* routinely a model. A model asked for a summary emits `##` headings and heavy
|
|
17
|
+
* `**bold**` with no idea what surrounds them, so document scale puts the
|
|
18
|
+
* least-trustworthy content on the screen at the loudest rung on it.
|
|
19
|
+
*/
|
|
20
|
+
variant?: "document" | "embedded";
|
|
21
|
+
}
|
package/src/member_chip.tsx
CHANGED
|
@@ -36,15 +36,25 @@ interface MemberChipProps {
|
|
|
36
36
|
*/
|
|
37
37
|
export function MemberChip({ name, image, secondary, size = "md", style }: MemberChipProps) {
|
|
38
38
|
const displayName = name?.trim() || "Unknown";
|
|
39
|
+
// THE TEXT FOLLOWS THE RUNG. `size` used to scale the avatar alone, so a chip
|
|
40
|
+
// asked for the dense rung got a 24px avatar beside a 14px name — half-dense,
|
|
41
|
+
// and the mismatch surfaces wherever the chip sits INSIDE a sentence: a 12px
|
|
42
|
+
// "Logged by" running straight into a 14px name reads as two type systems
|
|
43
|
+
// meeting mid-phrase, and their line boxes (16 against 20) then refuse to
|
|
44
|
+
// share a baseline under any `alignItems: "center"`.
|
|
45
|
+
//
|
|
46
|
+
// Only `sm` steps down, because only `sm` claims to be dense; every larger
|
|
47
|
+
// rung keeps the body size, where a person's name belongs.
|
|
48
|
+
const textSize = size === "sm" ? "xs" : "sm";
|
|
39
49
|
return (
|
|
40
50
|
<View style={[styles.row, style]}>
|
|
41
51
|
<Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
|
|
42
52
|
<View style={styles.text}>
|
|
43
|
-
<Text userSelect="none" numberOfLines={1}>
|
|
53
|
+
<Text userSelect="none" size={textSize} numberOfLines={1}>
|
|
44
54
|
{displayName}
|
|
45
55
|
</Text>
|
|
46
56
|
{secondary ? (
|
|
47
|
-
<Text userSelect="none" size=
|
|
57
|
+
<Text userSelect="none" size={textSize} color="zinc-500" numberOfLines={1}>
|
|
48
58
|
{secondary}
|
|
49
59
|
</Text>
|
|
50
60
|
) : null}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Pressable } from "react-native";
|
|
2
|
+
import { TextLink } from "./text_link";
|
|
3
|
+
import type { TextSize } from "./text";
|
|
4
|
+
|
|
5
|
+
export interface TextDisclosureProps {
|
|
6
|
+
/** Open state — controlled, because the content it reveals is the caller's. */
|
|
7
|
+
expanded: boolean;
|
|
8
|
+
onToggle: (expanded: boolean) => void;
|
|
9
|
+
/** What is behind it, as a noun: "transcript", "3 earlier replies". The verb
|
|
10
|
+
* is supplied — a caller writing its own "Show …" ends up with two vocabularies
|
|
11
|
+
* for one control the first time somebody writes "View". */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Matches the prose it sits in. Default "sm". */
|
|
14
|
+
size?: TextSize;
|
|
15
|
+
/** Announced name, when `label` alone is ambiguous on a page with several. */
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
testID?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An in-prose DISCLOSURE: muted underlined text that reveals adjacent content in
|
|
22
|
+
* place — a transcript under its player, a long value under its summary.
|
|
23
|
+
*
|
|
24
|
+
* The counterpart to {@link TextLink}, and the pair is the point: underlined text
|
|
25
|
+
* either GOES somewhere or REVEALS something, and the INK says which.
|
|
26
|
+
*
|
|
27
|
+
* blue + `role="link"` (`Link`) → leaves this surface
|
|
28
|
+
* muted underline (this) → reveals more of it, right here
|
|
29
|
+
*
|
|
30
|
+
* Both answer "show me more"; only one takes the reader away, which is the single
|
|
31
|
+
* question the affordance exists to answer. Anything that MUTATES — saves, sends,
|
|
32
|
+
* deletes, opens an overlay — carries a control surface instead (`Button` in
|
|
33
|
+
* chrome, `InlineButton` on a field). See composition.md §"Commit & feedback surfaces".
|
|
34
|
+
*
|
|
35
|
+
* **Why this is a component rather than three lines at each call site.** It
|
|
36
|
+
* carries a contract, and every part of it is a thing a hand-roll gets wrong:
|
|
37
|
+
*
|
|
38
|
+
* - **`role="button"`, never `role="link"`.** The obvious reach is `Link`, which
|
|
39
|
+
* announces a destination — so a screen-reader user is told they are leaving a
|
|
40
|
+
* page that is about to unfold under them.
|
|
41
|
+
* - **Muted, never blue.** Blue is the promise of a trip. One caller passing the
|
|
42
|
+
* navigation ink is all it takes for underline to stop meaning anything.
|
|
43
|
+
* - **Reveal only.** The exception to "underline navigates" is this narrow, and a
|
|
44
|
+
* named component is what keeps it narrow — the next author reaching for quiet
|
|
45
|
+
* text to hang a DELETE on finds a name that does not fit.
|
|
46
|
+
*
|
|
47
|
+
* **What it is not.** Not `Accordion`: that is a list-row disclosure with a header
|
|
48
|
+
* and a chevron, right for a run of expandable rows and heavy furniture for a verb
|
|
49
|
+
* inside one. Not `Button color="muted"`: measured, that is transparent,
|
|
50
|
+
* borderless and undecorated at rest, so its affordance arrives only on hover —
|
|
51
|
+
* invisible to keyboard and touch, and against a text column it reads as a label.
|
|
52
|
+
*/
|
|
53
|
+
export function TextDisclosure(props: TextDisclosureProps) {
|
|
54
|
+
const { expanded, onToggle, label, size = "sm", accessibilityLabel, testID } = props;
|
|
55
|
+
return (
|
|
56
|
+
<Pressable
|
|
57
|
+
onPress={() => onToggle(!expanded)}
|
|
58
|
+
accessibilityRole="button"
|
|
59
|
+
// The STATE, announced. Without it a screen reader hears the same words
|
|
60
|
+
// whichever way the disclosure is sitting.
|
|
61
|
+
aria-expanded={expanded}
|
|
62
|
+
accessibilityLabel={accessibilityLabel}
|
|
63
|
+
testID={testID}
|
|
64
|
+
>
|
|
65
|
+
<TextLink size={size} color="muted">
|
|
66
|
+
{expanded ? `Hide ${label}` : `Show ${label}`}
|
|
67
|
+
</TextLink>
|
|
68
|
+
</Pressable>
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/text_link.tsx
CHANGED
|
@@ -26,9 +26,19 @@ export interface TextLinkProps extends TextProps {
|
|
|
26
26
|
* The act mode briefly became its own surface-less component and is now gone
|
|
27
27
|
* entirely: it made underline mean two things separable only by ink, and it competed
|
|
28
28
|
* with the `Button` colour ladder for the job `muted` already does. **Underlined text
|
|
29
|
-
*
|
|
30
|
-
* `Button` in chrome, `InlineButton` on a field.
|
|
31
|
-
*
|
|
29
|
+
* GOES somewhere or REVEALS something; anything that MUTATES carries a control
|
|
30
|
+
* surface** — `Button` in chrome, `InlineButton` on a field.
|
|
31
|
+
*
|
|
32
|
+
* The reveal half is the one narrow exception, and this component is how you build
|
|
33
|
+
* it: an in-prose DISCLOSURE (a transcript under its player, a long value under its
|
|
34
|
+
* summary) wrapped in your own `Pressable`, `color="muted"` so it never wears the
|
|
35
|
+
* navigation blue. It exists because the rule used to end "a quiet verb that cannot
|
|
36
|
+
* sit against a text column belongs in chrome" — and a disclosure cannot go to
|
|
37
|
+
* chrome, it belongs to the thing above it. The two controls that fit chrome both
|
|
38
|
+
* fail here: a `muted` Button is transparent and undecorated at rest (a hover-only
|
|
39
|
+
* affordance), and an `Accordion` is a list row nested inside a list row. The INK is
|
|
40
|
+
* what keeps underline unambiguous — blue leaves, muted stays. See composition.md
|
|
41
|
+
* §"Commit & feedback surfaces".
|
|
32
42
|
*/
|
|
33
43
|
export function TextLink(props: TextLinkProps) {
|
|
34
44
|
const { icon, href, children, color, style, ...textProps } = props;
|