@lotics/ui 27.17.0 → 28.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -10
- package/MIGRATION.md +64 -0
- package/docs/catalog.md +97 -140
- package/docs/data_entry.md +57 -87
- package/docs/templates.md +95 -57
- package/examples/tpl_item_list.tsx +77 -36
- package/examples/tpl_record.tsx +372 -460
- package/package.json +3 -3
- package/src/checklist.tsx +339 -0
- package/src/comments_button.tsx +108 -0
- package/src/control_surface.ts +13 -0
- package/src/detail_row.tsx +2 -2
- package/src/field_annotations.tsx +1 -1
- package/src/icon.tsx +10 -0
- package/src/icon_button.tsx +17 -1
- package/src/locale.tsx +12 -1
- package/src/stepper.tsx +107 -18
- package/src/stepper_layout.ts +21 -1
- package/src/table.tsx +101 -11
- package/src/pipeline.tsx +0 -231
- package/src/task.tsx +0 -518
- package/src/task_metrics.ts +0 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "28.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"./agent_progress": "./src/agent_progress.tsx",
|
|
94
94
|
"./approval_prompt": "./src/approval_prompt.tsx",
|
|
95
95
|
"./message_actions": "./src/message_actions.tsx",
|
|
96
|
+
"./comments_button": "./src/comments_button.tsx",
|
|
96
97
|
"./copy_button": "./src/copy_button.tsx",
|
|
97
98
|
"./copy_text": {
|
|
98
99
|
"react-native": "./src/copy_text.ts",
|
|
@@ -176,6 +177,7 @@
|
|
|
176
177
|
"./form_field": "./src/form_field.tsx",
|
|
177
178
|
"./checkbox_input": "./src/checkbox_input.tsx",
|
|
178
179
|
"./check_circle": "./src/check_circle.tsx",
|
|
180
|
+
"./checklist": "./src/checklist.tsx",
|
|
179
181
|
"./radio_picker": "./src/radio_picker.tsx",
|
|
180
182
|
"./form_text_input": "./src/form_text_input.tsx",
|
|
181
183
|
"./form_switch": "./src/form_switch.tsx",
|
|
@@ -194,7 +196,6 @@
|
|
|
194
196
|
"./detail_row": "./src/detail_row.tsx",
|
|
195
197
|
"./record_summary": "./src/record_summary.tsx",
|
|
196
198
|
"./capture_row": "./src/capture_row.tsx",
|
|
197
|
-
"./task": "./src/task.tsx",
|
|
198
199
|
"./suggestion_chip": "./src/suggestion_chip.tsx",
|
|
199
200
|
"./danger_zone": "./src/danger_zone.tsx",
|
|
200
201
|
"./scan_field": "./src/scan_field.tsx",
|
|
@@ -216,7 +217,6 @@
|
|
|
216
217
|
"./card": "./src/card.tsx",
|
|
217
218
|
"./accordion": "./src/accordion.tsx",
|
|
218
219
|
"./stepper": "./src/stepper.tsx",
|
|
219
|
-
"./pipeline": "./src/pipeline.tsx",
|
|
220
220
|
"./step_progress": "./src/step_progress.tsx",
|
|
221
221
|
"./tabs": "./src/tabs.tsx",
|
|
222
222
|
"./segmented_control": "./src/segmented_control.tsx",
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { Pressable, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { TextLink } from "./text_link";
|
|
5
|
+
import { Stepper, Step, type StepPositional } from "./stepper";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* THE CHECKLIST — an ordered run of rows a reader ticks off, optionally grouped
|
|
10
|
+
* into phases, optionally strung on a connecting spine.
|
|
11
|
+
*
|
|
12
|
+
* It replaces two families that had converged on one anatomy. `task` modelled N
|
|
13
|
+
* items ticked in any order; `pipeline` modelled ONE thing walking N positions.
|
|
14
|
+
* That distinction is real about the DATA and turned out to be invisible in the
|
|
15
|
+
* COMPONENT: both ended up as a ring, a title, a value beside it, a muted note,
|
|
16
|
+
* a detail block and an optional grouping level — and the proof is that a single
|
|
17
|
+
* afternoon added `trailing`/`meta` to `TaskTitle` and, separately, the same two
|
|
18
|
+
* to `PipelineStep`, then `tone` to `TaskCaption` and `tone`+`action` to
|
|
19
|
+
* `PipelineNote`. One feature, implemented twice, because the surfaces were
|
|
20
|
+
* twins. What actually differed was a LINE between the rings, which is a mode,
|
|
21
|
+
* and the kit's own rule is that a mode is a variant rather than a second
|
|
22
|
+
* component.
|
|
23
|
+
*
|
|
24
|
+
* The ORDERING lives in the caller's model, not here. Whether ticking rung 4
|
|
25
|
+
* implies 1–3, whether un-ticking walks the record back, what a tick even MEANS
|
|
26
|
+
* (reached, or stamped) — only the caller's data knows. This renders rows.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Checklist connected>
|
|
30
|
+
* <ChecklistGroup title="Sales" />
|
|
31
|
+
* <ChecklistItem title="Quote sent" done onToggle={…} trailing={<DateStamp …/>}>
|
|
32
|
+
* <ChecklistNote tone="warning" action={{ label: "Set in General", onPress }}>
|
|
33
|
+
* Nobody is assigned to this desk.
|
|
34
|
+
* </ChecklistNote>
|
|
35
|
+
* <ChecklistActions><Button title="Hand off" /></ChecklistActions>
|
|
36
|
+
* </ChecklistItem>
|
|
37
|
+
* <ChecklistGroup title="Operations" />
|
|
38
|
+
* <ChecklistItem title="Collected" onToggle={…} />
|
|
39
|
+
* </Checklist>
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Groups and items are SIBLINGS, not nested. One run, one spine, one ring column,
|
|
43
|
+
* one text edge — a group is simply a row that carries a name and no control.
|
|
44
|
+
* Nesting them put the group's own lines on a different x from its items' and
|
|
45
|
+
* needed two containers to reconcile.
|
|
46
|
+
*/
|
|
47
|
+
export interface ChecklistProps {
|
|
48
|
+
children?: ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* String the rings on a connecting line — for a run whose rows are POSITIONS
|
|
51
|
+
* (one record walking a process), where the line says "these are sequential".
|
|
52
|
+
* Off for a set of independent items, where a line would claim an order the
|
|
53
|
+
* work does not have.
|
|
54
|
+
*/
|
|
55
|
+
connected?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* What the mark MEANS: `check` (default) for rows someone COMPLETES — a ring —
|
|
58
|
+
* and `select` for rows someone PICKS, which wear the square box selection
|
|
59
|
+
* wears everywhere else in the kit.
|
|
60
|
+
*
|
|
61
|
+
* A document picker is the case: "produce this form" is a choice, not work
|
|
62
|
+
* carried out, and a filled RING there reads as already done to anyone who has
|
|
63
|
+
* learned what a ring means. Same anatomy, same gutter; only the mark differs.
|
|
64
|
+
*/
|
|
65
|
+
mark?: "check" | "select";
|
|
66
|
+
/** Accent ink for reached rings + the line. Defaults to neutral. */
|
|
67
|
+
color?: string;
|
|
68
|
+
accessibilityLabel?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Checklist({ children, connected = true, mark = "check", color, accessibilityLabel }: ChecklistProps) {
|
|
72
|
+
return (
|
|
73
|
+
<Stepper
|
|
74
|
+
orientation="vertical"
|
|
75
|
+
connected={connected}
|
|
76
|
+
mark={mark === "select" ? "box" : "ring"}
|
|
77
|
+
color={color}
|
|
78
|
+
gap={CHECKLIST_ROW_GAP}
|
|
79
|
+
accessibilityLabel={accessibilityLabel}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</Stepper>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Between rows. Tight, because these are one-line rows rather than stages
|
|
87
|
+
* carrying bodies: at a group's rhythm the line between two of them stretches
|
|
88
|
+
* until it reads as a dash instead of a spine. */
|
|
89
|
+
const CHECKLIST_ROW_GAP = 8;
|
|
90
|
+
/** Above a group heading — the air that makes it a heading rather than the last
|
|
91
|
+
* row's caption. Larger than the row gap on purpose; the two are a pair. */
|
|
92
|
+
const CHECKLIST_GROUP_TOP = 18;
|
|
93
|
+
/** Under a group heading. A little more than rows give each other (8) — enough
|
|
94
|
+
* that the name reads as heading all of them rather than belonging to the first,
|
|
95
|
+
* and no more: the heading and its first row are one block, so air between them
|
|
96
|
+
* works against the grouping it is there to state. */
|
|
97
|
+
const CHECKLIST_GROUP_BOTTOM = 4;
|
|
98
|
+
|
|
99
|
+
export interface ChecklistGroupProps extends StepPositional {
|
|
100
|
+
/** The phase's name. */
|
|
101
|
+
title: string;
|
|
102
|
+
/**
|
|
103
|
+
* Whether this phase's rows are showing. CONTROLLED — and deliberately: who may
|
|
104
|
+
* be open at once is the CALLER's rule (one at a time, several, or the live one
|
|
105
|
+
* by default), and a component-local default would silently pick one. The group
|
|
106
|
+
* does not hide its own rows either: they are its SIBLINGS in the run, so the
|
|
107
|
+
* caller simply does not render them.
|
|
108
|
+
*/
|
|
109
|
+
open?: boolean;
|
|
110
|
+
/** Show the disclosure — a small pressable text beside the name. Omit for a
|
|
111
|
+
* phase that is always open. */
|
|
112
|
+
onToggleOpen?: () => void;
|
|
113
|
+
/** Override the disclosure's wording; otherwise the locale's. */
|
|
114
|
+
labels?: { expand?: string; collapse?: string };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A PHASE — a name over the rows that follow it, and nothing else.
|
|
119
|
+
*
|
|
120
|
+
* OPTIONAL: a flat checklist renders none, and one run may group only part of
|
|
121
|
+
* itself. It carries no control, because a phase owns no completion of its own —
|
|
122
|
+
* its rows do. A ring here would be a control that never responds, and one
|
|
123
|
+
* identical to the pressable rings under it invites the press it will refuse,
|
|
124
|
+
* which is worse than showing nothing: the reader clicks and the product says
|
|
125
|
+
* nothing back.
|
|
126
|
+
*
|
|
127
|
+
* It carries no BODY either. A condition or an act hung off a heading leaves the
|
|
128
|
+
* reader working out which of the rows beneath it they were about; on the row
|
|
129
|
+
* that owes the work there is no ambiguity.
|
|
130
|
+
*
|
|
131
|
+
* TYPE: `sm`, medium, full ink — the treatment a BAND heading already wears
|
|
132
|
+
* elsewhere on a record (a billing group, a ledger band). One surface should not
|
|
133
|
+
* teach two looks for the same job, and a reader who has learned that this is
|
|
134
|
+
* what "a named group of rows" looks like should not have to learn it again a
|
|
135
|
+
* section later.
|
|
136
|
+
*
|
|
137
|
+
* It was briefly an uppercase muted eyebrow, on the reasoning that a heading at
|
|
138
|
+
* the row's own `sm` medium is indistinguishable from the `sm` medium a CURRENT
|
|
139
|
+
* row wears. True of the TYPE alone, and irrelevant here: a group carries no
|
|
140
|
+
* ring and sits on the ring column's own left edge, while every row is ringed and
|
|
141
|
+
* indented past it. The structure already separates them, so spending case and
|
|
142
|
+
* ink on saying it again only made the label quieter than the prose it organises
|
|
143
|
+
* — which inverts the hierarchy it exists to state.
|
|
144
|
+
*/
|
|
145
|
+
export function ChecklistGroup({ title, open, onToggleOpen, labels, ...positional }: ChecklistGroupProps) {
|
|
146
|
+
const locale = useLoticsLocale();
|
|
147
|
+
const word = open ? labels?.collapse ?? locale.checklist.collapse : labels?.expand ?? locale.checklist.expand;
|
|
148
|
+
return (
|
|
149
|
+
<Step status="upcoming" marker={false} {...positional}>
|
|
150
|
+
<View
|
|
151
|
+
style={{
|
|
152
|
+
paddingTop: positional._first ? 0 : CHECKLIST_GROUP_TOP,
|
|
153
|
+
// A closed phase needs no air beneath it — the space belongs to the rows
|
|
154
|
+
// it is heading, and kept when there are none it reads as a gap the
|
|
155
|
+
// reader looks into for something that is not there.
|
|
156
|
+
paddingBottom: onToggleOpen != null && !open ? 0 : CHECKLIST_GROUP_BOTTOM,
|
|
157
|
+
flexDirection: "row",
|
|
158
|
+
alignItems: "baseline",
|
|
159
|
+
gap: 10,
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<Text size="sm" weight="medium">
|
|
163
|
+
{title}
|
|
164
|
+
</Text>
|
|
165
|
+
{/* A TEXT link, not a chevron: the phase is a heading rather than a row, so
|
|
166
|
+
a disclosure triangle would give it the affordance of something that
|
|
167
|
+
opens INTO a place. This only shows and hides rows already in the run,
|
|
168
|
+
and it says which in words. */}
|
|
169
|
+
{onToggleOpen != null ? (
|
|
170
|
+
<Pressable onPress={onToggleOpen} accessibilityRole="button" accessibilityLabel={`${word} · ${title}`}>
|
|
171
|
+
<TextLink size="xs">{word}</TextLink>
|
|
172
|
+
</Pressable>
|
|
173
|
+
) : null}
|
|
174
|
+
</View>
|
|
175
|
+
</Step>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Declares that this component renders a markerless row, so `Stepper` can END the
|
|
181
|
+
* line above it rather than running one into a column that draws nothing.
|
|
182
|
+
*
|
|
183
|
+
* A STATIC on the component, not a guess about its name or its rendered output:
|
|
184
|
+
* the run is built from siblings, so the only thing that can know a heading is
|
|
185
|
+
* coming is the heading's own type, and it says so itself.
|
|
186
|
+
*/
|
|
187
|
+
ChecklistGroup.markerless = true as const;
|
|
188
|
+
|
|
189
|
+
export interface ChecklistItemProps extends StepPositional {
|
|
190
|
+
/** The row's name. Doubles as the ring's accessible name — the ring carries no
|
|
191
|
+
* visible label of its own, so the text rides beside it. */
|
|
192
|
+
title: string;
|
|
193
|
+
/** Ticked. */
|
|
194
|
+
done?: boolean;
|
|
195
|
+
/** The row the reader is waiting on — medium weight, so a run full of ticks
|
|
196
|
+
* still says which one is owed. */
|
|
197
|
+
current?: boolean;
|
|
198
|
+
/** Tick / un-tick. Omit for a run that only reports. */
|
|
199
|
+
onToggle?: (done: boolean) => void;
|
|
200
|
+
/**
|
|
201
|
+
* ONE value on the title's row — in practice the row's own STAMP (a
|
|
202
|
+
* `DateStamp`), which is how a milestone gets BACKDATED. The tick cannot: it
|
|
203
|
+
* writes today, and a run filled in after the fact is full of days that already
|
|
204
|
+
* passed.
|
|
205
|
+
*
|
|
206
|
+
* The row's OWN value only. A field with a home elsewhere — an assignee, an
|
|
207
|
+
* address, a portal login that a section already owns — is reported by a
|
|
208
|
+
* `ChecklistNote` naming the gap, never edited here: a list that also collects
|
|
209
|
+
* gives every row two modes and asks the reader which one they are in.
|
|
210
|
+
*/
|
|
211
|
+
trailing?: ReactNode;
|
|
212
|
+
/** SHORT muted text after `trailing` — how long this row has been owed, who it
|
|
213
|
+
* waits on. Two words; prose that grows is a `ChecklistNote`, which owns its
|
|
214
|
+
* own line. */
|
|
215
|
+
meta?: string;
|
|
216
|
+
/** The row's overflow — an `ActionMenu`, at the end of its title row. */
|
|
217
|
+
menu?: ReactNode;
|
|
218
|
+
/** The row's own notes, acts and detail. */
|
|
219
|
+
children?: ReactNode;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** ONE ROW — the level that carries the ticks. */
|
|
223
|
+
export function ChecklistItem({
|
|
224
|
+
title,
|
|
225
|
+
done,
|
|
226
|
+
current,
|
|
227
|
+
onToggle,
|
|
228
|
+
trailing,
|
|
229
|
+
meta,
|
|
230
|
+
menu,
|
|
231
|
+
children,
|
|
232
|
+
...positional
|
|
233
|
+
}: ChecklistItemProps) {
|
|
234
|
+
return (
|
|
235
|
+
<Step
|
|
236
|
+
status={done ? "done" : current ? "current" : "upcoming"}
|
|
237
|
+
onToggle={onToggle}
|
|
238
|
+
accessibilityLabel={title}
|
|
239
|
+
{...positional}
|
|
240
|
+
>
|
|
241
|
+
<View style={{ gap: 6 }}>
|
|
242
|
+
{/* BASELINE, not centre: the title is `sm` and the meta `xs`, and two
|
|
243
|
+
sizes centred in one row share no baseline — the smaller floats.
|
|
244
|
+
WRAPS, so a long title drops its stamp to the next line rather than
|
|
245
|
+
crushing it. */}
|
|
246
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>
|
|
247
|
+
<Text size="sm" weight={current ? "medium" : "regular"} color={done ? "muted" : "default"}>
|
|
248
|
+
{title}
|
|
249
|
+
</Text>
|
|
250
|
+
{trailing}
|
|
251
|
+
{meta ? (
|
|
252
|
+
<Text size="xs" color="muted">
|
|
253
|
+
{meta}
|
|
254
|
+
</Text>
|
|
255
|
+
) : null}
|
|
256
|
+
{menu != null ? <View style={{ marginLeft: "auto" }}>{menu}</View> : null}
|
|
257
|
+
</View>
|
|
258
|
+
{children != null ? <View style={{ gap: 8 }}>{children}</View> : null}
|
|
259
|
+
</View>
|
|
260
|
+
</Step>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ChecklistNoteProps {
|
|
265
|
+
children: ReactNode;
|
|
266
|
+
/** `warning` for something outstanding, `danger` for a refusal, `muted` for a
|
|
267
|
+
* plain remark. */
|
|
268
|
+
tone?: "muted" | "warning" | "danger";
|
|
269
|
+
/**
|
|
270
|
+
* Where the reader goes to resolve it — the section that OWNS the missing
|
|
271
|
+
* value, jumped to by name.
|
|
272
|
+
*
|
|
273
|
+
* This is the whole answer to "that value is not set". A checklist REPORTS: it
|
|
274
|
+
* does not collect, and it does not PREVIEW. An editor here is a second edit
|
|
275
|
+
* surface for one fact; a rendered value — an avatar, a badge — is a copy that
|
|
276
|
+
* has to be kept in step with the field's real home, and readers start treating
|
|
277
|
+
* the list as the place the value lives. Naming the gap and handing over the
|
|
278
|
+
* navigation is the complete job.
|
|
279
|
+
*/
|
|
280
|
+
action?: { label: string; onPress: () => void };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A CONDITION on one row, in words. Its own line, never inline: two words that
|
|
284
|
+
* ride the title belong in `ChecklistItem.meta`, and prose that grows would wrap
|
|
285
|
+
* into the title. */
|
|
286
|
+
export function ChecklistNote({ children, tone = "muted", action }: ChecklistNoteProps) {
|
|
287
|
+
const text = (
|
|
288
|
+
<Text size="xs" color={tone}>
|
|
289
|
+
{children}
|
|
290
|
+
</Text>
|
|
291
|
+
);
|
|
292
|
+
if (!action) return text;
|
|
293
|
+
return (
|
|
294
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", flexWrap: "wrap", columnGap: 6 }}>
|
|
295
|
+
{text}
|
|
296
|
+
<Pressable onPress={action.onPress} accessibilityRole="button" accessibilityLabel={action.label}>
|
|
297
|
+
<TextLink size="xs">{action.label}</TextLink>
|
|
298
|
+
</Pressable>
|
|
299
|
+
</View>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The act(s) that complete or leave this row. Wraps on a narrow column so a row
|
|
304
|
+
* with two verbs never pushes the run sideways. */
|
|
305
|
+
export function ChecklistActions({ children }: { children: ReactNode }) {
|
|
306
|
+
return <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap", paddingTop: 2 }}>{children}</View>;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface ChecklistFieldProps {
|
|
310
|
+
/** The value's name, stacked ABOVE the control: the row's content column is
|
|
311
|
+
* already indented past the ring, and a second fixed column inside it leaves
|
|
312
|
+
* nothing for the value. */
|
|
313
|
+
label?: string;
|
|
314
|
+
children: ReactNode;
|
|
315
|
+
/** Cap the control so a lone text input does not run the full column. */
|
|
316
|
+
maxWidth?: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A value the ROW itself owns, editable in place — and the narrow exception to
|
|
321
|
+
* "a checklist reports".
|
|
322
|
+
*
|
|
323
|
+
* Reach for it only when the value has NO OTHER HOME: a portal login created at
|
|
324
|
+
* this step and existing on no other surface would otherwise be unreachable the
|
|
325
|
+
* moment the row is behind you. Anything a section already owns is that section's
|
|
326
|
+
* to collect, and this row's `ChecklistNote` names the gap instead.
|
|
327
|
+
*/
|
|
328
|
+
export function ChecklistField({ label, children, maxWidth = 320 }: ChecklistFieldProps) {
|
|
329
|
+
return (
|
|
330
|
+
<View style={{ gap: 2, maxWidth }}>
|
|
331
|
+
{label ? (
|
|
332
|
+
<Text size="xs" color="muted">
|
|
333
|
+
{label}
|
|
334
|
+
</Text>
|
|
335
|
+
) : null}
|
|
336
|
+
{children}
|
|
337
|
+
</View>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { StyleSheet, type GestureResponderEvent } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
+
import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
8
|
+
|
|
9
|
+
export interface CommentsButtonProps {
|
|
10
|
+
/** How many comments the thread holds. Render the button only where there ARE
|
|
11
|
+
* some — see the note on zero below. */
|
|
12
|
+
count: number;
|
|
13
|
+
/** What the thread is about — the record's name. It only reaches the accessible
|
|
14
|
+
* label, which is where a screen-reader user needs it: "3 comments on Northwind
|
|
15
|
+
* Packaging" is a destination, "3 comments" on the fortieth row is not. */
|
|
16
|
+
subject?: string;
|
|
17
|
+
/** Open the discussion. A count the reader cannot act on states that a
|
|
18
|
+
* conversation exists and leaves them to find it. */
|
|
19
|
+
onPress?: (event: GestureResponderEvent) => void;
|
|
20
|
+
/** `sm` (the register row's) or `md`. Matches `CopyButton`'s scale so the two
|
|
21
|
+
* affordances a row carries sit at one size. */
|
|
22
|
+
size?: "sm" | "md";
|
|
23
|
+
/** Override the wording; otherwise the locale's. */
|
|
24
|
+
labels?: { comments?: string; comment?: string };
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
testID?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A row's DISCUSSION — the count, the bubble, and the way in.
|
|
31
|
+
*
|
|
32
|
+
* Reach for it wherever a record is listed and its thread would otherwise be
|
|
33
|
+
* invisible until opened: a register row, a card, a compact header. It is the
|
|
34
|
+
* peer of `CopyButton` — the other thing a dense row lets you press — and shares
|
|
35
|
+
* its surface, its scale and its focus ring, so a row carrying both reads as one
|
|
36
|
+
* grammar rather than two authors.
|
|
37
|
+
*
|
|
38
|
+
* **ONE control, not a number beside a button.** The count and the glyph are the
|
|
39
|
+
* same target: the number IS what the reader aims at ("three comments" is the
|
|
40
|
+
* thing they saw), and splitting them leaves half the affordance dead under the
|
|
41
|
+
* pointer. It was briefly an `IconButton` with the count as loose text — which
|
|
42
|
+
* gave the glyph a hover state and the number none.
|
|
43
|
+
*
|
|
44
|
+
* **Full ink.** Everything else on a register row is a value ABOUT the record;
|
|
45
|
+
* this is people talking about it, and it is the one thing on the row that can be
|
|
46
|
+
* UNREAD. Muted, it files itself with the phone number.
|
|
47
|
+
*
|
|
48
|
+
* **Render it only when `count > 0`.** A zero on every quiet row is a column of
|
|
49
|
+
* noise that trains the eye to skip exactly where the signal will appear. The
|
|
50
|
+
* component does not decide that for you — an empty-state row may legitimately
|
|
51
|
+
* want a way to START a thread — but a register should not.
|
|
52
|
+
*/
|
|
53
|
+
export function CommentsButton(props: CommentsButtonProps) {
|
|
54
|
+
const { count, subject, onPress, size = "sm", labels, disabled, testID } = props;
|
|
55
|
+
const locale = useLoticsLocale();
|
|
56
|
+
const noun = count === 1 ? labels?.comment ?? locale.commentsButton.comment : labels?.comments ?? locale.commentsButton.comments;
|
|
57
|
+
// The subject rides the NAME, never the visible label: a row already says whose
|
|
58
|
+
// record it is, and repeating it beside the count spends the row's width on
|
|
59
|
+
// something the eye has just read.
|
|
60
|
+
const name = subject ? `${count} ${noun} · ${subject}` : `${count} ${noun}`;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<PressableHighlight
|
|
64
|
+
focusRing
|
|
65
|
+
testID={testID}
|
|
66
|
+
tooltip={name}
|
|
67
|
+
accessibilityRole="button"
|
|
68
|
+
accessibilityLabel={name}
|
|
69
|
+
onPress={onPress}
|
|
70
|
+
disabled={disabled || onPress == null}
|
|
71
|
+
// The row it rides hovers to `zinc[100]`, so this takes the row-control
|
|
72
|
+
// wash a step darker — matching `IconButton`, so the two things a dense row
|
|
73
|
+
// lets you press respond identically.
|
|
74
|
+
style={(state: { hovered?: boolean; pressed?: boolean }) => [
|
|
75
|
+
styles.button,
|
|
76
|
+
styles[size],
|
|
77
|
+
{ backgroundColor: state.pressed ? ROW_CONTROL_PRESS : state.hovered ? ROW_CONTROL_HOVER : undefined },
|
|
78
|
+
disabled && styles.disabled,
|
|
79
|
+
]}
|
|
80
|
+
// The same 40px touch target `IconButton` reserves at these sizes, without
|
|
81
|
+
// the visual box growing into the row.
|
|
82
|
+
hitSlop={size === "sm" ? 8 : 6}
|
|
83
|
+
>
|
|
84
|
+
<Icon size={size === "sm" ? 14 : 16} name="message-square" color={colors.zinc[900]} />
|
|
85
|
+
<Text size="xs" weight="medium" tabular>
|
|
86
|
+
{count}
|
|
87
|
+
</Text>
|
|
88
|
+
</PressableHighlight>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const styles = StyleSheet.create({
|
|
93
|
+
button: {
|
|
94
|
+
flexDirection: "row",
|
|
95
|
+
alignItems: "center",
|
|
96
|
+
// Tight: the glyph and its number are ONE word, not two things in a row.
|
|
97
|
+
gap: 3,
|
|
98
|
+
// A pill rather than `IconButton`'s circle — it holds two glyphs' worth of
|
|
99
|
+
// content, and a circle sized to fit them would balloon beside a 24px copy
|
|
100
|
+
// control on the same line.
|
|
101
|
+
borderRadius: 999,
|
|
102
|
+
},
|
|
103
|
+
// 24 at `sm` — the SAME box `IconButton` reserves there, so a row carrying
|
|
104
|
+
// both lands them on one baseline and one height.
|
|
105
|
+
sm: { paddingHorizontal: 6, height: 24 },
|
|
106
|
+
md: { paddingHorizontal: 8, height: 28 },
|
|
107
|
+
disabled: { opacity: 0.3 },
|
|
108
|
+
});
|
package/src/control_surface.ts
CHANGED
|
@@ -56,6 +56,19 @@ export const CONTROL_TEXT_INSET = 9;
|
|
|
56
56
|
* `CONTROL_TRANSITION` so the change eases in. */
|
|
57
57
|
export const HOVER_BORDER = colors.zinc[400];
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The wash a control paints when it rides a HOVERABLE surface — a register row,
|
|
61
|
+
* a list item, a card.
|
|
62
|
+
*
|
|
63
|
+
* One step darker than the generic hover, and it has to be. A row's own hover is
|
|
64
|
+
* `zinc[100]`; a control that also hovers to `zinc[100]` paints the same grey on
|
|
65
|
+
* the same grey the moment the pointer is over both — which is always, since
|
|
66
|
+
* reaching the control means crossing the row. The control then reads as dead
|
|
67
|
+
* exactly when the reader is pointing at it, and no amount of size fixes that.
|
|
68
|
+
*/
|
|
69
|
+
export const ROW_CONTROL_HOVER = colors.zinc[200];
|
|
70
|
+
export const ROW_CONTROL_PRESS = colors.zinc[300];
|
|
71
|
+
|
|
59
72
|
/** The ease applied to a bordered control's interactive edges (border + ring), so
|
|
60
73
|
* hover/focus transitions read smooth, not snapped. Web-only; ignored on native. */
|
|
61
74
|
export const CONTROL_TRANSITION = {
|
package/src/detail_row.tsx
CHANGED
|
@@ -18,7 +18,7 @@ interface DetailTableColumns {
|
|
|
18
18
|
const DetailTableContext = createContext<DetailTableColumns | null>(null);
|
|
19
19
|
|
|
20
20
|
/** THE kit's label column — ~18 characters, where real field names live. It is
|
|
21
|
-
* this table's default
|
|
21
|
+
* this table's default, so a record's fields and a checklist's
|
|
22
22
|
* read as one language on a surface carrying both; that only holds while the
|
|
23
23
|
* two are ONE number. A name longer than the column WRAPS inside it: the
|
|
24
24
|
* column belongs to the SURFACE, so no one field's name may push its own value
|
|
@@ -158,7 +158,7 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
158
158
|
);
|
|
159
159
|
// THE field-annotation anatomy — severity, tones, a11y and the control's own
|
|
160
160
|
// text inset, defined once in `field_annotations` and rendered identically by
|
|
161
|
-
// a
|
|
161
|
+
// a checklist row's field. A record's field and a checklist's are the same
|
|
162
162
|
// thing on two surfaces; they may not say a fault two different ways.
|
|
163
163
|
const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
|
|
164
164
|
if (table?.stacked) {
|
|
@@ -4,7 +4,7 @@ import { Text } from "./text";
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What a FIELD says about itself under its own value — ONE anatomy, shared by every field
|
|
7
|
-
* surface in the kit
|
|
7
|
+
* surface in the kit — `DetailRow` on a record, `ChecklistField` on a checklist row. A record's field and a
|
|
8
8
|
* task's field carry the same three tones, in the same order, with the same a11y, because they
|
|
9
9
|
* ARE the same thing on two surfaces: a name, a value the reader sets, and whatever has to be
|
|
10
10
|
* said about it. Where the two disagreed, an app with a field-level fault had to demote it into
|
package/src/icon.tsx
CHANGED
|
@@ -9,8 +9,18 @@
|
|
|
9
9
|
* (find the file name via: grep "IconName" node_modules/lucide-react-native/dist/esm/lucide-react-native.js)
|
|
10
10
|
* 2. Add the icon to the iconComponents object with kebab-case key
|
|
11
11
|
* The type will be automatically derived from the object keys.
|
|
12
|
+
*
|
|
13
|
+
* The reference below is load-bearing, not tidiness: lucide ships these icon
|
|
14
|
+
* files as JS with no `.d.ts` and does not list them in its `exports`, so the
|
|
15
|
+
* ambient declaration beside this file is the only thing that types them. An
|
|
16
|
+
* ambient file joins a program only when the program INCLUDES it, and a
|
|
17
|
+
* consumer's `include` covers the consumer's own directory — never ours. Every
|
|
18
|
+
* consumer therefore has to reach it through the file that needs it, which is
|
|
19
|
+
* this one.
|
|
12
20
|
*/
|
|
13
21
|
|
|
22
|
+
/// <reference path="./lucide-react-native.d.ts" />
|
|
23
|
+
|
|
14
24
|
import { View } from "react-native";
|
|
15
25
|
import Activity from "lucide-react-native/dist/esm/icons/activity";
|
|
16
26
|
import TextAlignCenter from "lucide-react-native/dist/esm/icons/text-align-center";
|
package/src/icon_button.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { ActivityIndicator, GestureResponderEvent, StyleProp, StyleSheet, View,
|
|
|
2
2
|
import { Icon, IconName } from "./icon";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
+
import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
|
|
5
6
|
import { type ButtonColor } from "./button";
|
|
6
7
|
import { getButtonIconColor } from "./button_colors";
|
|
7
8
|
import { Ref } from "react";
|
|
@@ -80,7 +81,22 @@ export function IconButton(props: IconButtonProps) {
|
|
|
80
81
|
tooltipSide={tooltipSide}
|
|
81
82
|
accessibilityRole="button"
|
|
82
83
|
accessibilityLabel={accessibilityLabel ?? tooltip}
|
|
83
|
-
|
|
84
|
+
// A glyph-only control rides ROWS more often than it stands alone, and a
|
|
85
|
+
// row hovers to `zinc[100]` — so the generic hover would paint the same
|
|
86
|
+
// grey on the same grey the moment the pointer reaches this, which is
|
|
87
|
+
// always. The colored variants keep their own fill; only `none`/`white`,
|
|
88
|
+
// which have no fill to darken, take the row-control wash.
|
|
89
|
+
style={(state: { hovered?: boolean; pressed?: boolean }) => [
|
|
90
|
+
styles.button,
|
|
91
|
+
styles[size],
|
|
92
|
+
styles[color],
|
|
93
|
+
elevated && styles.elevated,
|
|
94
|
+
color === "none" || color === "white"
|
|
95
|
+
? { backgroundColor: state.pressed ? ROW_CONTROL_PRESS : state.hovered ? ROW_CONTROL_HOVER : undefined }
|
|
96
|
+
: null,
|
|
97
|
+
disabledOrLoading && styles.disabled,
|
|
98
|
+
style,
|
|
99
|
+
]}
|
|
84
100
|
onPress={handlePress}
|
|
85
101
|
disabled={disabledOrLoading}
|
|
86
102
|
// 40px touch target regardless of visual size (28 + 2×6 / 24 + 2×8)
|
package/src/locale.tsx
CHANGED
|
@@ -69,10 +69,14 @@ export interface LoticsLocale {
|
|
|
69
69
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
70
70
|
ledger: { rowDetails: (label: string) => string };
|
|
71
71
|
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
72
|
-
/** `Step`/`
|
|
72
|
+
/** `Step`/`ChecklistItem`'s toggleable MARKER, when the caller names no
|
|
73
73
|
* label of its own. A pipeline stage always passes its title, so this is the
|
|
74
74
|
* bare-`Step` fallback. */
|
|
75
75
|
stepper: { complete: string; progress: string };
|
|
76
|
+
/** `ChecklistGroup`'s disclosure — the small pressable text that shows or hides
|
|
77
|
+
* a phase's rows. The kit owns the wording so one app does not say "Show" while
|
|
78
|
+
* the next says "Expand" for the same gesture. */
|
|
79
|
+
checklist: { expand: string; collapse: string };
|
|
76
80
|
sectionHeading: { info: string };
|
|
77
81
|
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
78
82
|
* phrased identically everywhere instead of hand-written per app. */
|
|
@@ -213,6 +217,9 @@ export interface LoticsLocale {
|
|
|
213
217
|
* copied stays a per-instance `label` ("Copy phone number") — that names a
|
|
214
218
|
* value only the call site knows, the same split `referenceField.open` draws. */
|
|
215
219
|
copyButton: { copy: string; copied: string };
|
|
220
|
+
/** `CommentsButton`'s accessible name — the kit owns the noun so one surface
|
|
221
|
+
* does not say "comments" while the next says "notes" for the same thread. */
|
|
222
|
+
commentsButton: { comment: string; comments: string };
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
/** The platform default — English. Every component's hardcoded default lives
|
|
@@ -245,6 +252,7 @@ export const en: LoticsLocale = {
|
|
|
245
252
|
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
246
253
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
247
254
|
stepper: { complete: "Complete step", progress: "Progress" },
|
|
255
|
+
checklist: { expand: "Show", collapse: "Hide" },
|
|
248
256
|
sectionHeading: { info: "About this data" },
|
|
249
257
|
errorState: { retry: "Try again" },
|
|
250
258
|
chip: { remove: "Remove" },
|
|
@@ -375,6 +383,7 @@ export const en: LoticsLocale = {
|
|
|
375
383
|
approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
|
|
376
384
|
messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
|
|
377
385
|
copyButton: { copy: "Copy", copied: "Copied" },
|
|
386
|
+
commentsButton: { comment: "comment", comments: "comments" },
|
|
378
387
|
};
|
|
379
388
|
|
|
380
389
|
/** Vietnamese. Maintained once here so every app (and the frontend) shares one
|
|
@@ -407,6 +416,7 @@ export const vi: LoticsLocale = {
|
|
|
407
416
|
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" },
|
|
408
417
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
409
418
|
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
419
|
+
checklist: { expand: "Mở", collapse: "Thu gọn" },
|
|
410
420
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
411
421
|
errorState: { retry: "Thử lại" },
|
|
412
422
|
chip: { remove: "Xóa" },
|
|
@@ -534,6 +544,7 @@ export const vi: LoticsLocale = {
|
|
|
534
544
|
approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
|
|
535
545
|
messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
|
|
536
546
|
copyButton: { copy: "Sao chép", copied: "Đã sao chép" },
|
|
547
|
+
commentsButton: { comment: "bình luận", comments: "bình luận" },
|
|
537
548
|
};
|
|
538
549
|
|
|
539
550
|
const LoticsLocaleContext = createContext<LoticsLocale>(en);
|