@lotics/ui 18.1.0 → 19.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/MIGRATION.md +65 -0
- package/docs/catalog.md +78 -26
- package/docs/data_entry.md +55 -21
- package/docs/templates.md +17 -17
- package/examples/tpl_item_list.tsx +26 -26
- package/examples/tpl_record.tsx +38 -38
- package/package.json +1 -1
- package/src/detail_row.tsx +7 -1
- package/src/file_grid.tsx +5 -0
- package/src/file_thumbnail.tsx +27 -10
- package/src/file_thumbnail_grid.tsx +4 -0
- package/src/linked_record_box.tsx +30 -13
- package/src/task.tsx +347 -135
- package/src/task_metrics.test.ts +48 -0
- package/src/task_metrics.ts +55 -0
package/package.json
CHANGED
package/src/detail_row.tsx
CHANGED
|
@@ -19,6 +19,12 @@ const DetailTableContext = createContext<DetailTableColumns | null>(null);
|
|
|
19
19
|
* side-by-side columns and stacks — below it, editors get crushed. */
|
|
20
20
|
const MIN_VALUE_WIDTH = 160;
|
|
21
21
|
|
|
22
|
+
/** THE kit's label column — ~18 characters, where real field names live. It is
|
|
23
|
+
* this table's default AND the floor a `TaskSubRow`'s label sizes from, so a
|
|
24
|
+
* record's fields and a task's read as one language on a surface carrying
|
|
25
|
+
* both; that only holds while the two are ONE number. */
|
|
26
|
+
export const DETAIL_LABEL_WIDTH = 130;
|
|
27
|
+
|
|
22
28
|
export interface DetailTableProps {
|
|
23
29
|
/** Label column width (px). Default 130. */
|
|
24
30
|
labelWidth?: number;
|
|
@@ -64,7 +70,7 @@ export interface DetailTableProps {
|
|
|
64
70
|
* the switch is automatic.
|
|
65
71
|
*/
|
|
66
72
|
export function DetailTable(props: DetailTableProps) {
|
|
67
|
-
const { labelWidth =
|
|
73
|
+
const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
|
|
68
74
|
const [width, setWidth] = useState<number | null>(null);
|
|
69
75
|
const stacked =
|
|
70
76
|
width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
|
package/src/file_grid.tsx
CHANGED
|
@@ -59,6 +59,10 @@ export interface FileGridProps {
|
|
|
59
59
|
* e.g. open the gallery at the first hidden file. */
|
|
60
60
|
onOverflowPress?: (hiddenCount: number) => void;
|
|
61
61
|
style?: ViewStyle;
|
|
62
|
+
/** Render every tile INERT — no press, no `button` role, no tab stop. Use it
|
|
63
|
+
* when something ABOVE the grid owns the press (a table cell, a popover
|
|
64
|
+
* trigger, a pressable row): a tile that keeps its button nests one button
|
|
65
|
+
* inside another. */
|
|
62
66
|
disablePress?: boolean;
|
|
63
67
|
partialRowAlign?: "start" | "end";
|
|
64
68
|
selectedIds?: ReadonlySet<string>;
|
|
@@ -162,6 +166,7 @@ export function FileGrid(props: FileGridProps) {
|
|
|
162
166
|
file={item.file}
|
|
163
167
|
size={size}
|
|
164
168
|
onPress={disablePress ? undefined : () => handleFilePress(item.file)}
|
|
169
|
+
disablePress={disablePress}
|
|
165
170
|
onRemove={onDisplayRemove ? () => onDisplayRemove(item.removeId) : undefined}
|
|
166
171
|
selected={selectedIds?.has(item.file.id)}
|
|
167
172
|
/>
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -66,6 +66,12 @@ interface FileThumbnailProps {
|
|
|
66
66
|
size?: number;
|
|
67
67
|
/** Called when thumbnail is pressed */
|
|
68
68
|
onPress?: () => void;
|
|
69
|
+
/** Render the tile INERT — no press handler, no `button` role, no tab stop.
|
|
70
|
+
* Without this an `onPress`-less tile still falls back to opening the file,
|
|
71
|
+
* so it stays a `<button>`: dropping a strip into a pressable row (a table
|
|
72
|
+
* cell, a `PressableRow`) then nests a button in a button. Set it whenever
|
|
73
|
+
* something ABOVE the tile owns the press. */
|
|
74
|
+
disablePress?: boolean;
|
|
69
75
|
/** Called when thumbnail is long-pressed */
|
|
70
76
|
onLongPress?: () => void;
|
|
71
77
|
/** Called when the completed file is removed */
|
|
@@ -87,8 +93,15 @@ interface FileThumbnailProps {
|
|
|
87
93
|
* Works with any file source via DisplayFile interface.
|
|
88
94
|
*/
|
|
89
95
|
export function FileThumbnail(props: FileThumbnailProps) {
|
|
90
|
-
const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate } = props;
|
|
96
|
+
const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate, disablePress } = props;
|
|
91
97
|
const accessibilityLabel = props.accessibilityLabel ?? file.filename;
|
|
98
|
+
// A tile with no `onPress` still OPENS the file — a useful default for a bare
|
|
99
|
+
// thumbnail in a list or a gallery. But a caller that said `disablePress`
|
|
100
|
+
// means it: inventing a handler there renders a <button>, and the one place a
|
|
101
|
+
// compact strip belongs is a table cell, which is itself pressable. Nesting a
|
|
102
|
+
// button in a button is invalid HTML and two tab stops for one action.
|
|
103
|
+
const press = disablePress ? undefined : onPress;
|
|
104
|
+
const pressOrOpen = disablePress ? undefined : (onPress ?? (() => Linking.openURL(file.url)));
|
|
92
105
|
|
|
93
106
|
const rootStyle =
|
|
94
107
|
size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
|
|
@@ -96,7 +109,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
96
109
|
if (isImageMimeType(file.mimeType)) {
|
|
97
110
|
return (
|
|
98
111
|
<View style={rootStyle}>
|
|
99
|
-
<ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={
|
|
112
|
+
<ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={press} onLongPress={onLongPress} />
|
|
100
113
|
{uploading && <UploadingOverlay size={size} />}
|
|
101
114
|
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
102
115
|
{selected !== undefined && <SelectionOverlay selected={selected} />}
|
|
@@ -114,7 +127,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
114
127
|
size={size}
|
|
115
128
|
isTemplate={isTemplate}
|
|
116
129
|
accessibilityLabel={accessibilityLabel}
|
|
117
|
-
onPress={
|
|
130
|
+
onPress={pressOrOpen}
|
|
118
131
|
onLongPress={onLongPress}
|
|
119
132
|
/>
|
|
120
133
|
{uploading && <UploadingOverlay size={size} />}
|
|
@@ -132,7 +145,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
132
145
|
icon={mediaIcon}
|
|
133
146
|
size={size}
|
|
134
147
|
accessibilityLabel={accessibilityLabel}
|
|
135
|
-
onPress={
|
|
148
|
+
onPress={pressOrOpen}
|
|
136
149
|
onLongPress={onLongPress}
|
|
137
150
|
/>
|
|
138
151
|
) : (
|
|
@@ -142,7 +155,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
142
155
|
size={size}
|
|
143
156
|
isTemplate={isTemplate}
|
|
144
157
|
accessibilityLabel={accessibilityLabel}
|
|
145
|
-
onPress={
|
|
158
|
+
onPress={pressOrOpen}
|
|
146
159
|
onLongPress={onLongPress}
|
|
147
160
|
/>
|
|
148
161
|
)}
|
|
@@ -206,7 +219,8 @@ export function DocumentBadge(props: DocumentBadgeProps) {
|
|
|
206
219
|
<Pressable
|
|
207
220
|
onPress={onPress}
|
|
208
221
|
onLongPress={onLongPress}
|
|
209
|
-
accessibilityRole="button"
|
|
222
|
+
accessibilityRole={onPress ? "button" : undefined}
|
|
223
|
+
tabIndex={onPress ? undefined : -1}
|
|
210
224
|
accessibilityLabel={accessibilityLabel ?? resolveMime(mimeType).label}
|
|
211
225
|
{...focusProps}
|
|
212
226
|
style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}
|
|
@@ -262,7 +276,8 @@ export function DocumentCard(props: DocumentCardProps) {
|
|
|
262
276
|
]}
|
|
263
277
|
onPress={onPress}
|
|
264
278
|
onLongPress={onLongPress}
|
|
265
|
-
accessibilityRole="button"
|
|
279
|
+
accessibilityRole={onPress ? "button" : undefined}
|
|
280
|
+
tabIndex={onPress ? undefined : -1}
|
|
266
281
|
accessibilityLabel={a11yLabel}
|
|
267
282
|
{...focusProps}
|
|
268
283
|
>
|
|
@@ -313,7 +328,8 @@ export function MediaCard(props: MediaCardProps) {
|
|
|
313
328
|
style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
314
329
|
onPress={onPress}
|
|
315
330
|
onLongPress={onLongPress}
|
|
316
|
-
accessibilityRole="button"
|
|
331
|
+
accessibilityRole={onPress ? "button" : undefined}
|
|
332
|
+
tabIndex={onPress ? undefined : -1}
|
|
317
333
|
accessibilityLabel={a11yLabel}
|
|
318
334
|
{...focusProps}
|
|
319
335
|
>
|
|
@@ -327,7 +343,8 @@ export function MediaCard(props: MediaCardProps) {
|
|
|
327
343
|
const backdrop = iconSize + 20;
|
|
328
344
|
|
|
329
345
|
return (
|
|
330
|
-
<Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button"
|
|
346
|
+
<Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : undefined}
|
|
347
|
+
tabIndex={onPress ? undefined : -1} accessibilityLabel={a11yLabel} {...focusProps}>
|
|
331
348
|
{(state) => (
|
|
332
349
|
<>
|
|
333
350
|
<View style={[styles.mediaCardBody, { backgroundColor: color }]}>
|
|
@@ -381,7 +398,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
|
|
|
381
398
|
: { width: "100%" as const, height: "100%" as const };
|
|
382
399
|
|
|
383
400
|
return (
|
|
384
|
-
<Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={a11yLabel} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
|
|
401
|
+
<Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} tabIndex={onPress ? undefined : -1} accessibilityLabel={a11yLabel} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
|
|
385
402
|
{(state) => (
|
|
386
403
|
<>
|
|
387
404
|
<Image
|
|
@@ -188,6 +188,10 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
|
|
|
188
188
|
file={file}
|
|
189
189
|
size={size}
|
|
190
190
|
onPress={disablePress || !onFilePress ? undefined : () => onFilePress(file)}
|
|
191
|
+
// Withholding `onPress` is NOT enough: a tile with none falls back to
|
|
192
|
+
// opening the file, so it stays a button and a tab stop. The prop has
|
|
193
|
+
// to reach the tile that honors it.
|
|
194
|
+
disablePress={disablePress}
|
|
191
195
|
onRemove={onRemove ? () => onRemove(file.id) : undefined}
|
|
192
196
|
selected={selectedIds?.has(file.id)}
|
|
193
197
|
/>
|
|
@@ -5,6 +5,7 @@ import { PressDoor } from "./press_door";
|
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { Icon, type IconName } from "./icon";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
8
9
|
|
|
9
10
|
/** One reference fact. `V` is the value the variant admits — text under a door, any node without one. */
|
|
10
11
|
interface LinkedRecordFact<V = ReactNode> {
|
|
@@ -94,18 +95,30 @@ export function LinkedRecordBox(props: LinkedRecordBoxProps) {
|
|
|
94
95
|
</View>
|
|
95
96
|
{facts.length > 0 ? (
|
|
96
97
|
<View style={{ gap: 6 }}>
|
|
97
|
-
{facts.map((f) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
98
|
+
{facts.map((f) => {
|
|
99
|
+
// THE ALIGNMENT LAW, the same one `DetailRow` runs on: the fact TOP-aligns and each
|
|
100
|
+
// cell centres within the first CONTROL LINE. A node value brings the kit's control
|
|
101
|
+
// band (an inline editor is 40 tall, a chip less), so both cells claim it — without
|
|
102
|
+
// it the label `Text`, stretched to the row, printed its words at the band's TOP,
|
|
103
|
+
// a half-band above the value it names. A STRING value has no band: label and value
|
|
104
|
+
// are the same 20px line, so they sit at the row's top and a long value that wraps
|
|
105
|
+
// still tops out level with its label.
|
|
106
|
+
const banded = typeof f.value !== "string";
|
|
107
|
+
return (
|
|
108
|
+
<View key={f.label} style={styles.fact}>
|
|
109
|
+
<View style={[{ width: factLabelWidth }, banded && styles.bandedCell]}>
|
|
110
|
+
<Text size="sm" color="muted">{f.label}</Text>
|
|
111
|
+
</View>
|
|
112
|
+
{banded ? (
|
|
113
|
+
// A node prints as authored — the "—" placeholder is a TEXT affordance, and a
|
|
114
|
+
// control renders its own empty state.
|
|
115
|
+
<View style={[{ flex: 1, minWidth: 0 }, styles.bandedCell]}>{f.value}</View>
|
|
116
|
+
) : (
|
|
117
|
+
<Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
|
|
118
|
+
)}
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
109
122
|
</View>
|
|
110
123
|
) : null}
|
|
111
124
|
{actions ? (
|
|
@@ -134,7 +147,11 @@ const styles = StyleSheet.create({
|
|
|
134
147
|
box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
|
|
135
148
|
identity: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
136
149
|
glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
|
|
137
|
-
fact: { flexDirection: "row", gap: 10 },
|
|
150
|
+
fact: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
|
|
151
|
+
// A cell on the control band: exactly `minHeight` tall for a one-line label / a short chip,
|
|
152
|
+
// and taller content (a wrapped label, a stack of controls) tops out level instead of being
|
|
153
|
+
// dragged toward the block's centre.
|
|
154
|
+
bandedCell: { minHeight: INLINE_CONTROL_HEIGHT, justifyContent: "center" },
|
|
138
155
|
divider: { height: 1, backgroundColor: colors.zinc[100], marginHorizontal: -16 },
|
|
139
156
|
actions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 },
|
|
140
157
|
});
|