@lotics/ui 41.1.0 → 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 +83 -0
- package/docs/ai_patterns.md +36 -0
- package/docs/catalog.md +50 -11
- package/docs/composition.md +89 -11
- package/docs/data_entry.md +63 -0
- package/docs/templates.md +120 -9
- package/examples/tpl_record.tsx +763 -9
- package/package.json +3 -1
- 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.
|
|
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",
|
|
@@ -194,6 +195,7 @@
|
|
|
194
195
|
"./counter": "./src/counter.tsx",
|
|
195
196
|
"./link": "./src/link.tsx",
|
|
196
197
|
"./reference_field": "./src/reference_field.tsx",
|
|
198
|
+
"./text_disclosure": "./src/text_disclosure.tsx",
|
|
197
199
|
"./text_link": "./src/text_link.tsx",
|
|
198
200
|
"./sort_header": "./src/sort_header.tsx",
|
|
199
201
|
"./skeleton": "./src/skeleton.tsx",
|
|
@@ -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;
|
package/src/timeline.tsx
CHANGED
|
@@ -14,11 +14,55 @@ export interface TimelineItem {
|
|
|
14
14
|
* renders a tinted disc of this color; the icon takes the full color. */
|
|
15
15
|
iconColor: string;
|
|
16
16
|
isLoading?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The row's identity line — CLAMPED TO TWO LINES, because it is a scan target
|
|
19
|
+
* before it is a sentence. A feed is the one place user-authored prose lands in
|
|
20
|
+
* a row primitive, and unclamped it eats the section: measured on a real
|
|
21
|
+
* interaction log, one 700-character summary drew a 180px label — nine lines
|
|
22
|
+
* for one entry, and the disc, which centres on the label row, ended up 80px
|
|
23
|
+
* below the first line it was meant to sit beside.
|
|
24
|
+
*
|
|
25
|
+
* So a label that can run long belongs in `details` as well, in full. The
|
|
26
|
+
* clamp is the contract rather than a prop: a prop would let the next caller
|
|
27
|
+
* re-open the same hole, and the whole point is that the row keeps its beat
|
|
28
|
+
* whatever the caller hands it.
|
|
29
|
+
*/
|
|
17
30
|
label: string;
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
31
|
+
/**
|
|
32
|
+
* The label is a STAND-IN the surface supplied, not a value the record holds —
|
|
33
|
+
* render it in the placeholder tone the kit uses for an unfilled field.
|
|
34
|
+
*
|
|
35
|
+
* A feed fills up from more than one direction: a person writes an entry, but
|
|
36
|
+
* an automation also drops one the moment a recording lands, and an extraction
|
|
37
|
+
* files one from a screenshot. Those arrive with no words in them, and a row
|
|
38
|
+
* whose identity line is `string` forces the caller to invent some — which
|
|
39
|
+
* then renders in body ink, indistinguishable from a note somebody actually
|
|
40
|
+
* wrote. "Nobody has written this up" and "this is what happened" must not
|
|
41
|
+
* look alike; without this flag the type makes them.
|
|
42
|
+
*/
|
|
43
|
+
placeholder?: boolean;
|
|
44
|
+
/** An always-visible sub-line under the label (a note, a detail) — clamped to
|
|
45
|
+
* two lines, same reason. When set, the row top-aligns so `right` sits next to
|
|
46
|
+
* the label, not centred on the block. */
|
|
20
47
|
description?: string;
|
|
21
48
|
error?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Trailing content — a duration, a badge, a status. It renders BESIDE the press
|
|
51
|
+
* target rather than inside it, so an interactive one stays clickable on its
|
|
52
|
+
* own: nesting a control in the row's own button is invalid HTML (`<button>`
|
|
53
|
+
* cannot contain `<button>`) and hands one click to two handlers. The chevron
|
|
54
|
+
* closes the pressable region; `right` sits after it.
|
|
55
|
+
*
|
|
56
|
+
* **On a row that EXPANDS, prefer putting verbs in the body instead.** A
|
|
57
|
+
* control sharing the label row with the press target produced three separate
|
|
58
|
+
* defects here, and they are not independent: it nested a button in a button,
|
|
59
|
+
* it needed a fixed-height box of its own to stay on the label's first line,
|
|
60
|
+
* and it sat close enough to the chevron to read as one cluster with it. All
|
|
61
|
+
* three exist only because something interactive shares the row. In the body
|
|
62
|
+
* there is no press target to share with, and the verbs sit next to the
|
|
63
|
+
* content they act on — where an expansion the reader chose is also the look
|
|
64
|
+
* before a destructive act. Keep `right` for what it is good at: a fact.
|
|
65
|
+
*/
|
|
22
66
|
right?: ReactNode;
|
|
23
67
|
details?: ReactNode;
|
|
24
68
|
}
|
|
@@ -50,20 +94,44 @@ export function Timeline(props: TimelineProps) {
|
|
|
50
94
|
const isFirst = index === 0;
|
|
51
95
|
const isLast = index === items.length - 1;
|
|
52
96
|
|
|
53
|
-
// The label row: label +
|
|
54
|
-
// (description/error) render BELOW in the spine band
|
|
55
|
-
//
|
|
56
|
-
//
|
|
97
|
+
// The label row: label + chevron only. Secondary lines
|
|
98
|
+
// (description/error) render BELOW in the spine band.
|
|
99
|
+
//
|
|
100
|
+
// Everything in this band aligns to the label's FIRST LINE, never to the
|
|
101
|
+
// label BLOCK — see FIRST_LINE_CENTRE. That is why the row tops its
|
|
102
|
+
// children and the chevron carries an offset instead of being centred:
|
|
103
|
+
// centring matches first-line alignment exactly until a label wraps, and
|
|
104
|
+
// then quietly stops.
|
|
57
105
|
const row = (
|
|
58
106
|
<View style={styles.row}>
|
|
59
|
-
<Text
|
|
60
|
-
|
|
107
|
+
<Text
|
|
108
|
+
size="sm"
|
|
109
|
+
numberOfLines={2}
|
|
110
|
+
// The same ink an unfilled field draws (`Not set`, `No date set`),
|
|
111
|
+
// so an unwritten row reads as unwritten everywhere it appears.
|
|
112
|
+
color={item.placeholder ? "zinc-400" : undefined}
|
|
113
|
+
style={{ flex: 1 }}
|
|
114
|
+
>
|
|
115
|
+
{item.label}
|
|
116
|
+
</Text>
|
|
61
117
|
{hasDetails ? (
|
|
62
|
-
<
|
|
118
|
+
<View style={styles.onFirstLine}>
|
|
119
|
+
<Icon name={expanded ? "chevron-up" : "chevron-down"} size={14} color={colors.zinc[400]} />
|
|
120
|
+
</View>
|
|
63
121
|
) : null}
|
|
64
122
|
</View>
|
|
65
123
|
);
|
|
66
124
|
|
|
125
|
+
// `right` centres inside a first-line-tall box, so a duration, a badge
|
|
126
|
+
// and a row-action menu all land on the label's first line whatever
|
|
127
|
+
// their own height, and stay there when the label wraps. Which box
|
|
128
|
+
// depends on whether it sits inside the row's padding or beside it.
|
|
129
|
+
const trailing = item.right ? (
|
|
130
|
+
<View style={hasDetails ? styles.onFirstLineOutdented : styles.onFirstLine}>
|
|
131
|
+
{item.right}
|
|
132
|
+
</View>
|
|
133
|
+
) : null;
|
|
134
|
+
|
|
67
135
|
return (
|
|
68
136
|
<View key={item.id}>
|
|
69
137
|
{/* Band 1 — disc beside the label row, vertically CENTERED on it.
|
|
@@ -71,7 +139,7 @@ export function Timeline(props: TimelineProps) {
|
|
|
71
139
|
through tall rows. */}
|
|
72
140
|
<View style={styles.labelBand}>
|
|
73
141
|
<View style={styles.discColumn}>
|
|
74
|
-
<View style={styles.
|
|
142
|
+
<View style={styles.spineAbove}>{!isFirst ? <View style={styles.spineFill} /> : null}</View>
|
|
75
143
|
<AnimationFadeIn key={`${item.id}-${item.icon}`}>
|
|
76
144
|
{/* Tinted disc: a low-alpha wash of the accent behind a full-color icon. */}
|
|
77
145
|
<View style={[styles.node, { backgroundColor: withAlpha(item.iconColor, 0.1) }]}>
|
|
@@ -82,15 +150,23 @@ export function Timeline(props: TimelineProps) {
|
|
|
82
150
|
)}
|
|
83
151
|
</View>
|
|
84
152
|
</AnimationFadeIn>
|
|
85
|
-
<View style={styles.
|
|
153
|
+
<View style={styles.spineBelow}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
|
|
86
154
|
</View>
|
|
87
155
|
<View style={styles.rowColumn}>
|
|
88
156
|
{hasDetails ? (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
// `right` is a SIBLING of the press target, never a child of
|
|
158
|
+
// it — see the prop's doc comment.
|
|
159
|
+
<View style={styles.rowSplit}>
|
|
160
|
+
<PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
|
|
161
|
+
{row}
|
|
162
|
+
</PressableHighlight>
|
|
163
|
+
{trailing}
|
|
164
|
+
</View>
|
|
92
165
|
) : (
|
|
93
|
-
<View style={styles.plainRow}>
|
|
166
|
+
<View style={styles.plainRow}>
|
|
167
|
+
{row}
|
|
168
|
+
{trailing}
|
|
169
|
+
</View>
|
|
94
170
|
)}
|
|
95
171
|
</View>
|
|
96
172
|
</View>
|
|
@@ -100,7 +176,7 @@ export function Timeline(props: TimelineProps) {
|
|
|
100
176
|
<View style={styles.belowBand}>
|
|
101
177
|
<View style={styles.spineColumn}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
|
|
102
178
|
<View style={[styles.belowColumn, isLast && !hasBelow ? styles.belowColumnLast : null]}>
|
|
103
|
-
{item.description ? <Text size="xs" color="muted">{item.description}</Text> : null}
|
|
179
|
+
{item.description ? <Text size="xs" color="muted" numberOfLines={2}>{item.description}</Text> : null}
|
|
104
180
|
{item.error ? (
|
|
105
181
|
<Text size="xs" color="danger" numberOfLines={1}>{item.error}</Text>
|
|
106
182
|
) : null}
|
|
@@ -114,19 +190,55 @@ export function Timeline(props: TimelineProps) {
|
|
|
114
190
|
);
|
|
115
191
|
}
|
|
116
192
|
|
|
193
|
+
/** Line box of the label (`Text size="sm"`). */
|
|
194
|
+
const LINE_HEIGHT = 20;
|
|
195
|
+
/** The row's vertical padding — also the press target's breathing room. */
|
|
196
|
+
const ROW_PAD_Y = 10;
|
|
197
|
+
/** The disc itself, and the air it keeps around it. */
|
|
198
|
+
const NODE_SIZE = 32;
|
|
199
|
+
const NODE_MARGIN_Y = 3;
|
|
200
|
+
/** The disc's own box — DERIVED, because `spineAbove` is sized by the difference
|
|
201
|
+
* between this and the first line. Hand-copied as `38` it drifted the moment
|
|
202
|
+
* either number above changed, and the symptom would have been the disc sliding
|
|
203
|
+
* off the very line FIRST_LINE_CENTRE exists to hold it on. */
|
|
204
|
+
const NODE_BOX = NODE_SIZE + NODE_MARGIN_Y * 2;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* WHERE THE LABEL'S FIRST LINE SITS, measured from the top of the label band —
|
|
208
|
+
* and the single number every ornament in that band derives from.
|
|
209
|
+
*
|
|
210
|
+
* It exists because "centre it on the row" and "put it on the first line" are
|
|
211
|
+
* the same answer right up until a label wraps, and then they are 10px apart,
|
|
212
|
+
* silently: the disc slides to the middle of the pair while the one-line rows
|
|
213
|
+
* beside it stay put, and the column stops being a column. Only the wrapped rows
|
|
214
|
+
* are wrong, so nothing about the screen says which number is the mistake.
|
|
215
|
+
*
|
|
216
|
+
* Every ornament reads this instead of picking its own — the disc, the chevron,
|
|
217
|
+
* and whatever a caller puts in `right`.
|
|
218
|
+
*/
|
|
219
|
+
const FIRST_LINE_CENTRE = ROW_PAD_Y + LINE_HEIGHT / 2;
|
|
220
|
+
|
|
117
221
|
const styles = StyleSheet.create({
|
|
118
222
|
labelBand: {
|
|
119
223
|
flexDirection: "row",
|
|
120
224
|
gap: 12,
|
|
121
225
|
},
|
|
122
|
-
// Stretches to the label row's height
|
|
123
|
-
//
|
|
226
|
+
// Stretches to the label row's height so the spine below the disc can fill it;
|
|
227
|
+
// the disc itself is PINNED to the first line rather than centred on the block
|
|
228
|
+
// (see FIRST_LINE_CENTRE).
|
|
124
229
|
discColumn: {
|
|
125
230
|
width: 32,
|
|
126
231
|
alignSelf: "stretch",
|
|
127
232
|
alignItems: "center",
|
|
128
233
|
},
|
|
129
|
-
|
|
234
|
+
// A fixed sliver above the disc puts its centre on the label's first line; the
|
|
235
|
+
// slot below flexes, so the spine still reaches the bottom of a tall row.
|
|
236
|
+
spineAbove: {
|
|
237
|
+
height: FIRST_LINE_CENTRE - NODE_BOX / 2,
|
|
238
|
+
alignItems: "center",
|
|
239
|
+
justifyContent: "center",
|
|
240
|
+
},
|
|
241
|
+
spineBelow: {
|
|
130
242
|
flex: 1,
|
|
131
243
|
alignItems: "center",
|
|
132
244
|
justifyContent: "center",
|
|
@@ -138,12 +250,12 @@ const styles = StyleSheet.create({
|
|
|
138
250
|
backgroundColor: colors.zinc[200],
|
|
139
251
|
},
|
|
140
252
|
node: {
|
|
141
|
-
width:
|
|
142
|
-
height:
|
|
143
|
-
borderRadius:
|
|
253
|
+
width: NODE_SIZE,
|
|
254
|
+
height: NODE_SIZE,
|
|
255
|
+
borderRadius: NODE_SIZE / 2,
|
|
144
256
|
justifyContent: "center",
|
|
145
257
|
alignItems: "center",
|
|
146
|
-
marginVertical:
|
|
258
|
+
marginVertical: NODE_MARGIN_Y,
|
|
147
259
|
},
|
|
148
260
|
rowColumn: {
|
|
149
261
|
flex: 1,
|
|
@@ -167,31 +279,79 @@ const styles = StyleSheet.create({
|
|
|
167
279
|
},
|
|
168
280
|
row: {
|
|
169
281
|
flexDirection: "row",
|
|
170
|
-
|
|
282
|
+
// TOP, not centre — the whole band aligns to the first line.
|
|
283
|
+
alignItems: "flex-start",
|
|
171
284
|
gap: 10,
|
|
172
285
|
flex: 1,
|
|
173
286
|
},
|
|
174
|
-
//
|
|
175
|
-
//
|
|
287
|
+
// Centres its content on the first line, for anything INSIDE the row's padding
|
|
288
|
+
// — the chevron, and `right` on a row that is not a press target.
|
|
289
|
+
//
|
|
290
|
+
// `height`, never `minHeight`. A floor lets the box GROW to whatever the
|
|
291
|
+
// caller passed, and a grown box centres its content on its own new middle
|
|
292
|
+
// rather than on the line: against these numbers a 28px `ActionMenu` in a
|
|
293
|
+
// plain row's `right` lands 4px low and a 40px control 10px low, while a 16px
|
|
294
|
+
// duration — the only `right` in the kit's own examples — sits perfectly,
|
|
295
|
+
// which is exactly how a defect like this survives review. A FIXED box
|
|
296
|
+
// overflows symmetrically instead (a View does not clip), so the content's
|
|
297
|
+
// centre stays pinned whatever its size.
|
|
298
|
+
onFirstLine: {
|
|
299
|
+
height: LINE_HEIGHT,
|
|
300
|
+
justifyContent: "center",
|
|
301
|
+
},
|
|
302
|
+
// The same job for `right` on an EXPANDABLE row, where it sits outside the
|
|
303
|
+
// press target and so does not inherit that padding. Two styles rather than
|
|
304
|
+
// one because the difference is real: same target line, different origin.
|
|
305
|
+
onFirstLineOutdented: {
|
|
306
|
+
height: FIRST_LINE_CENTRE * 2,
|
|
307
|
+
justifyContent: "center",
|
|
308
|
+
},
|
|
309
|
+
// Holds the press target and `right` side by side, so the wash marks exactly
|
|
310
|
+
// what a press acts on. The 8px BLEED lives here rather than on the pressable:
|
|
311
|
+
// owned by the child it would push `right` into the gap, and a wash sliding
|
|
312
|
+
// under a control is how a row action stops being visible at the moment the
|
|
313
|
+
// pointer reaches it.
|
|
314
|
+
rowSplit: {
|
|
315
|
+
flexDirection: "row",
|
|
316
|
+
alignItems: "flex-start",
|
|
317
|
+
// WIDER than the gap INSIDE the press target (10), because these are not
|
|
318
|
+
// peers: the chevron belongs to the row's own button, `right` does not. At 4
|
|
319
|
+
// the two sat 12px apart against the chevron's 10 — near enough to 1:1 that
|
|
320
|
+
// the eye read them as one cluster, with only a 4px break in the hover wash
|
|
321
|
+
// saying otherwise, which a reader who never hovers never sees. 20 against
|
|
322
|
+
// 10 is the ratio that makes them two things.
|
|
323
|
+
gap: 12,
|
|
324
|
+
marginHorizontal: -8,
|
|
325
|
+
},
|
|
326
|
+
// The press target for expandable rows; the horizontal padding puts the label
|
|
327
|
+
// back on the column's own left edge, which the bleed above moved off it.
|
|
328
|
+
//
|
|
329
|
+
// There is no `minHeight` any more, and none is missing: `ROW_PAD_Y` on both
|
|
330
|
+
// sides of one line IS the 40px touch target, so the two can no longer
|
|
331
|
+
// disagree. A floor above the content was also what made this row centre its
|
|
332
|
+
// children in slack it owned — the drift FIRST_LINE_CENTRE exists to remove.
|
|
176
333
|
pressableRow: {
|
|
334
|
+
flex: 1,
|
|
177
335
|
borderRadius: 8,
|
|
178
336
|
paddingHorizontal: 8,
|
|
179
|
-
paddingVertical:
|
|
180
|
-
marginHorizontal: -8,
|
|
181
|
-
minHeight: 40,
|
|
337
|
+
paddingVertical: ROW_PAD_Y,
|
|
182
338
|
flexDirection: "row",
|
|
183
|
-
alignItems: "
|
|
339
|
+
alignItems: "flex-start",
|
|
184
340
|
},
|
|
185
341
|
plainRow: {
|
|
186
|
-
paddingVertical:
|
|
187
|
-
minHeight: 40,
|
|
342
|
+
paddingVertical: ROW_PAD_Y,
|
|
188
343
|
flexDirection: "row",
|
|
189
|
-
alignItems: "
|
|
344
|
+
alignItems: "flex-start",
|
|
345
|
+
gap: 10,
|
|
190
346
|
},
|
|
347
|
+
// NO left padding. It was 2, which put a row's body 2px right of the
|
|
348
|
+
// description line directly above it — one column of text at two left edges,
|
|
349
|
+
// small enough to read as sloppiness rather than as a defect, and the reason a
|
|
350
|
+
// reader cannot say what is wrong with a screen that measures fine everywhere
|
|
351
|
+
// else. An indent is legitimate only when something VISIBLE occupies it.
|
|
191
352
|
detailsContainer: {
|
|
192
353
|
gap: 8,
|
|
193
354
|
paddingTop: 8,
|
|
194
|
-
paddingLeft: 2,
|
|
195
355
|
},
|
|
196
356
|
});
|
|
197
357
|
|