@colixsystems/widget-sdk 0.170.0 → 0.173.1
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/README.md +69 -3
- package/dist/contract.cjs +62 -4
- package/dist/contract.js +62 -4
- package/dist/detail-parts-tokens.js +91 -0
- package/dist/detail-parts-view.js +392 -0
- package/dist/detail-parts.js +15 -0
- package/dist/detail-parts.native.js +15 -0
- package/dist/index.d.ts +129 -4
- package/dist/index.js +4 -1
- package/dist/index.native.js +4 -1
- package/dist/initials.js +17 -0
- package/dist/list-tokens.js +31 -0
- package/dist/list-view.js +84 -18
- package/dist/list.js +3 -3
- package/dist/list.native.js +4 -4
- package/dist/webbundle.js +4 -0
- package/package.json +1 -1
- package/src/webbundle.js +4 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// The ONE implementation behind the `<Badge>`, `<Avatar>`, `<Progress>`,
|
|
2
|
+
// `<Stat>` and `<Divider>` SDK components and the stock widgets of the same
|
|
3
|
+
// names. The bindings (`detail-parts.js` / `detail-parts.native.js`) differ
|
|
4
|
+
// only in where the primitives come from, so the hosts cannot drift.
|
|
5
|
+
//
|
|
6
|
+
// Meaning is never carried by colour alone: a badge always prints its label, a
|
|
7
|
+
// progress its percent (unless the caller hides it), a stat's delta its sign.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { useHostTheme } from "./hooks.js";
|
|
11
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
12
|
+
import { lucideGlyph, Icon } from "./icon.js";
|
|
13
|
+
import { initialsFor } from "./initials.js";
|
|
14
|
+
import {
|
|
15
|
+
avatarPixelSize,
|
|
16
|
+
badgeTint,
|
|
17
|
+
clampPercent,
|
|
18
|
+
dividerSpacing,
|
|
19
|
+
dividerThickness,
|
|
20
|
+
hasStatValue,
|
|
21
|
+
progressColor,
|
|
22
|
+
statDeltaColor,
|
|
23
|
+
statFontSize,
|
|
24
|
+
} from "./detail-parts-tokens.js";
|
|
25
|
+
|
|
26
|
+
const RING_SIZE_PX = 72;
|
|
27
|
+
const RING_STROKE_PX = 8;
|
|
28
|
+
const h = React.createElement;
|
|
29
|
+
|
|
30
|
+
// Outside a widget host (a test, a preview) the defaults still paint.
|
|
31
|
+
function useDetailTheme() {
|
|
32
|
+
return useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function glyph(name, size, color) {
|
|
36
|
+
const Glyph = lucideGlyph(name);
|
|
37
|
+
return Glyph ? h(Glyph, { size, color }) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A count badge may be a number; an empty one still shows it is a badge.
|
|
41
|
+
function badgeText(label) {
|
|
42
|
+
if (typeof label === "number" && Number.isFinite(label)) return String(label);
|
|
43
|
+
return typeof label === "string" && label !== "" ? label : "Badge";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function captionText(value) {
|
|
47
|
+
return typeof value === "string" ? value.trim() : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bind the detail parts to one platform's primitives.
|
|
52
|
+
*
|
|
53
|
+
* @param {{View: any, Text: any, Image: any, Svg: any, Circle: any}} rn
|
|
54
|
+
*/
|
|
55
|
+
export function makeDetailParts(rn) {
|
|
56
|
+
const { View, Text, Image, Svg, Circle } = rn;
|
|
57
|
+
|
|
58
|
+
function Badge({ label, tone = "neutral", icon, style, testID }) {
|
|
59
|
+
const { colors, spacing, radii, typography } = useDetailTheme();
|
|
60
|
+
const tint = badgeTint(colors, tone);
|
|
61
|
+
const text = badgeText(label);
|
|
62
|
+
const pill = {
|
|
63
|
+
// A pill hugs its label instead of stretching across a column.
|
|
64
|
+
alignSelf: "flex-start",
|
|
65
|
+
flexDirection: "row",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
gap: spacing.xs,
|
|
68
|
+
borderRadius: radii.pill,
|
|
69
|
+
paddingVertical: spacing.xs,
|
|
70
|
+
paddingHorizontal: spacing.sm,
|
|
71
|
+
backgroundColor: tint.backgroundColor,
|
|
72
|
+
};
|
|
73
|
+
const labelStyle = {
|
|
74
|
+
color: tint.color,
|
|
75
|
+
fontSize: typography.sizes.xs,
|
|
76
|
+
fontWeight: "600",
|
|
77
|
+
fontFamily: typography.fontFamily,
|
|
78
|
+
};
|
|
79
|
+
return h(
|
|
80
|
+
View,
|
|
81
|
+
{ style: [pill, style], testID },
|
|
82
|
+
glyph(icon, typography.sizes.xs, tint.color),
|
|
83
|
+
h(Text, { style: labelStyle }, text),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
Badge.displayName = "Badge";
|
|
87
|
+
|
|
88
|
+
function Avatar({ name, url, size = "md", shape = "circle", accessibilityLabel, style, testID }) {
|
|
89
|
+
const { colors, radii, typography } = useDetailTheme();
|
|
90
|
+
const px = avatarPixelSize(size);
|
|
91
|
+
const box = {
|
|
92
|
+
alignSelf: "flex-start",
|
|
93
|
+
width: px,
|
|
94
|
+
height: px,
|
|
95
|
+
borderRadius: shape === "rounded" ? radii.md : px / 2,
|
|
96
|
+
backgroundColor: colors.surfaceMuted,
|
|
97
|
+
};
|
|
98
|
+
const source = typeof url === "string" ? url.trim() : "";
|
|
99
|
+
const label = accessibilityLabel || captionText(name) || "Avatar";
|
|
100
|
+
if (source) {
|
|
101
|
+
return h(Image, {
|
|
102
|
+
source: { uri: source },
|
|
103
|
+
accessibilityLabel: label,
|
|
104
|
+
resizeMode: "cover",
|
|
105
|
+
style: [box, style],
|
|
106
|
+
testID,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const initials = initialsFor(name);
|
|
110
|
+
const fallback = { alignItems: "center", justifyContent: "center" };
|
|
111
|
+
const content = initials
|
|
112
|
+
? h(
|
|
113
|
+
Text,
|
|
114
|
+
{
|
|
115
|
+
style: {
|
|
116
|
+
color: colors.onSurfaceMuted,
|
|
117
|
+
fontWeight: "600",
|
|
118
|
+
fontFamily: typography.fontFamily,
|
|
119
|
+
fontSize: Math.round(px * 0.4),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
initials,
|
|
123
|
+
)
|
|
124
|
+
: h(Icon, { name: "user", size: Math.round(px * 0.55), color: colors.onSurfaceMuted });
|
|
125
|
+
return h(View, { style: [box, fallback, style], accessibilityLabel: label, testID }, content);
|
|
126
|
+
}
|
|
127
|
+
Avatar.displayName = "Avatar";
|
|
128
|
+
|
|
129
|
+
function ProgressRing({ percent, fill, showValue, caption, theme }) {
|
|
130
|
+
const { colors, spacing, typography } = theme;
|
|
131
|
+
const radius = (RING_SIZE_PX - RING_STROKE_PX) / 2;
|
|
132
|
+
const circumference = 2 * Math.PI * radius;
|
|
133
|
+
const centre = RING_SIZE_PX / 2;
|
|
134
|
+
const ring = { cx: centre, cy: centre, r: radius, strokeWidth: RING_STROKE_PX, fill: "none" };
|
|
135
|
+
return h(
|
|
136
|
+
View,
|
|
137
|
+
{ style: { alignItems: "center" } },
|
|
138
|
+
h(
|
|
139
|
+
View,
|
|
140
|
+
{ style: { width: RING_SIZE_PX, height: RING_SIZE_PX } },
|
|
141
|
+
h(
|
|
142
|
+
Svg,
|
|
143
|
+
{ width: RING_SIZE_PX, height: RING_SIZE_PX },
|
|
144
|
+
h(Circle, { ...ring, stroke: colors.surfaceMuted }),
|
|
145
|
+
h(Circle, {
|
|
146
|
+
...ring,
|
|
147
|
+
stroke: fill,
|
|
148
|
+
strokeLinecap: "round",
|
|
149
|
+
strokeDasharray: `${circumference} ${circumference}`,
|
|
150
|
+
strokeDashoffset: circumference * (1 - percent / 100),
|
|
151
|
+
rotation: -90,
|
|
152
|
+
originX: centre,
|
|
153
|
+
originY: centre,
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
showValue
|
|
157
|
+
? h(
|
|
158
|
+
View,
|
|
159
|
+
{
|
|
160
|
+
style: {
|
|
161
|
+
position: "absolute",
|
|
162
|
+
top: 0,
|
|
163
|
+
left: 0,
|
|
164
|
+
right: 0,
|
|
165
|
+
bottom: 0,
|
|
166
|
+
alignItems: "center",
|
|
167
|
+
justifyContent: "center",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
h(
|
|
171
|
+
Text,
|
|
172
|
+
{
|
|
173
|
+
style: {
|
|
174
|
+
fontSize: typography.sizes.md,
|
|
175
|
+
fontWeight: "700",
|
|
176
|
+
color: colors.onSurface,
|
|
177
|
+
fontFamily: typography.fontFamily,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
`${Math.round(percent)}%`,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
: null,
|
|
184
|
+
),
|
|
185
|
+
caption
|
|
186
|
+
? h(
|
|
187
|
+
Text,
|
|
188
|
+
{
|
|
189
|
+
style: {
|
|
190
|
+
marginTop: spacing.xs,
|
|
191
|
+
fontSize: typography.sizes.sm,
|
|
192
|
+
color: colors.onSurfaceMuted,
|
|
193
|
+
fontFamily: typography.fontFamily,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
caption,
|
|
197
|
+
)
|
|
198
|
+
: null,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ProgressBar({ percent, fill, showValue, caption, theme }) {
|
|
203
|
+
const { colors, spacing, radii, typography } = theme;
|
|
204
|
+
const small = { fontSize: typography.sizes.sm, fontFamily: typography.fontFamily };
|
|
205
|
+
const header =
|
|
206
|
+
caption || showValue
|
|
207
|
+
? h(
|
|
208
|
+
View,
|
|
209
|
+
{
|
|
210
|
+
style: {
|
|
211
|
+
flexDirection: "row",
|
|
212
|
+
alignItems: "center",
|
|
213
|
+
justifyContent: "flex-end",
|
|
214
|
+
marginBottom: spacing.xs,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
caption
|
|
218
|
+
? h(Text, { style: { ...small, flex: 1, color: colors.onSurfaceMuted } }, caption)
|
|
219
|
+
: null,
|
|
220
|
+
showValue
|
|
221
|
+
? h(
|
|
222
|
+
Text,
|
|
223
|
+
{
|
|
224
|
+
style: {
|
|
225
|
+
...small,
|
|
226
|
+
marginLeft: spacing.xs,
|
|
227
|
+
fontWeight: "600",
|
|
228
|
+
color: colors.onSurface,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
`${Math.round(percent)}%`,
|
|
232
|
+
)
|
|
233
|
+
: null,
|
|
234
|
+
)
|
|
235
|
+
: null;
|
|
236
|
+
const track = {
|
|
237
|
+
width: "100%",
|
|
238
|
+
height: spacing.sm,
|
|
239
|
+
borderRadius: radii.pill,
|
|
240
|
+
backgroundColor: colors.surfaceMuted,
|
|
241
|
+
overflow: "hidden",
|
|
242
|
+
};
|
|
243
|
+
return h(
|
|
244
|
+
View,
|
|
245
|
+
{ style: { width: "100%" } },
|
|
246
|
+
header,
|
|
247
|
+
h(
|
|
248
|
+
View,
|
|
249
|
+
{ style: track },
|
|
250
|
+
h(View, {
|
|
251
|
+
style: {
|
|
252
|
+
height: "100%",
|
|
253
|
+
borderRadius: radii.pill,
|
|
254
|
+
width: `${percent}%`,
|
|
255
|
+
backgroundColor: fill,
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function Progress({ value = 0, variant = "bar", label, showValue = true, tone = "primary", style, testID }) {
|
|
263
|
+
const theme = useDetailTheme();
|
|
264
|
+
const percent = clampPercent(value);
|
|
265
|
+
const parts = {
|
|
266
|
+
percent,
|
|
267
|
+
fill: progressColor(theme.colors, tone),
|
|
268
|
+
showValue: showValue !== false,
|
|
269
|
+
caption: captionText(label),
|
|
270
|
+
theme,
|
|
271
|
+
};
|
|
272
|
+
return h(
|
|
273
|
+
View,
|
|
274
|
+
{
|
|
275
|
+
style: [{ width: "100%" }, style],
|
|
276
|
+
// The aria props, not accessibilityValue: react-native-web drops that one.
|
|
277
|
+
role: "progressbar",
|
|
278
|
+
"aria-valuemin": 0,
|
|
279
|
+
"aria-valuemax": 100,
|
|
280
|
+
"aria-valuenow": Math.round(percent),
|
|
281
|
+
testID,
|
|
282
|
+
},
|
|
283
|
+
variant === "ring" ? h(ProgressRing, parts) : h(ProgressBar, parts),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
Progress.displayName = "Progress";
|
|
287
|
+
|
|
288
|
+
function Stat({
|
|
289
|
+
value,
|
|
290
|
+
label,
|
|
291
|
+
icon,
|
|
292
|
+
delta,
|
|
293
|
+
deltaTone = "neutral",
|
|
294
|
+
emptyValue = "—",
|
|
295
|
+
color,
|
|
296
|
+
fontSize,
|
|
297
|
+
style,
|
|
298
|
+
testID,
|
|
299
|
+
}) {
|
|
300
|
+
const { colors, spacing, typography } = useDetailTheme();
|
|
301
|
+
const empty = !hasStatValue(value);
|
|
302
|
+
const shown = empty ? (typeof emptyValue === "string" ? emptyValue : "") : String(value);
|
|
303
|
+
const size = statFontSize(fontSize, typography);
|
|
304
|
+
const valueColor = empty ? colors.onSurfaceMuted : color || colors.onSurface;
|
|
305
|
+
const deltaText = captionText(delta);
|
|
306
|
+
const caption = typeof label === "string" ? label : "";
|
|
307
|
+
return h(
|
|
308
|
+
View,
|
|
309
|
+
{ style: [{ alignItems: "flex-start" }, style], testID },
|
|
310
|
+
h(
|
|
311
|
+
View,
|
|
312
|
+
{ style: { flexDirection: "row", alignItems: "center", gap: spacing.xs } },
|
|
313
|
+
glyph(icon, Math.max(12, Math.round(size * 0.75)), valueColor),
|
|
314
|
+
h(
|
|
315
|
+
Text,
|
|
316
|
+
{
|
|
317
|
+
style: {
|
|
318
|
+
fontWeight: "700",
|
|
319
|
+
fontFamily: typography.headingFontFamily,
|
|
320
|
+
fontSize: size,
|
|
321
|
+
color: valueColor,
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
shown,
|
|
325
|
+
),
|
|
326
|
+
deltaText
|
|
327
|
+
? h(
|
|
328
|
+
Text,
|
|
329
|
+
{
|
|
330
|
+
style: {
|
|
331
|
+
fontSize: typography.sizes.sm,
|
|
332
|
+
fontWeight: "600",
|
|
333
|
+
fontFamily: typography.fontFamily,
|
|
334
|
+
color: statDeltaColor(colors, deltaTone),
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
deltaText,
|
|
338
|
+
)
|
|
339
|
+
: null,
|
|
340
|
+
),
|
|
341
|
+
caption
|
|
342
|
+
? h(
|
|
343
|
+
Text,
|
|
344
|
+
{
|
|
345
|
+
style: {
|
|
346
|
+
marginTop: spacing.xs,
|
|
347
|
+
fontSize: typography.sizes.sm,
|
|
348
|
+
color: colors.onSurfaceMuted,
|
|
349
|
+
fontFamily: typography.fontFamily,
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
caption,
|
|
353
|
+
)
|
|
354
|
+
: null,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
Stat.displayName = "Stat";
|
|
358
|
+
|
|
359
|
+
function Divider({ label, thickness = 1, spacing = "md", color, style, testID }) {
|
|
360
|
+
const theme = useDetailTheme();
|
|
361
|
+
const caption = captionText(label);
|
|
362
|
+
const rule = {
|
|
363
|
+
height: dividerThickness(thickness),
|
|
364
|
+
backgroundColor: color || theme.colors.border,
|
|
365
|
+
};
|
|
366
|
+
const root = { width: "100%", paddingVertical: theme.spacing[dividerSpacing(spacing)] };
|
|
367
|
+
const body = caption
|
|
368
|
+
? h(
|
|
369
|
+
View,
|
|
370
|
+
{ style: { flexDirection: "row", alignItems: "center" } },
|
|
371
|
+
h(View, { style: [{ flex: 1 }, rule] }),
|
|
372
|
+
h(
|
|
373
|
+
Text,
|
|
374
|
+
{
|
|
375
|
+
style: {
|
|
376
|
+
marginHorizontal: theme.spacing.sm,
|
|
377
|
+
fontSize: theme.typography.sizes.sm,
|
|
378
|
+
color: theme.colors.onSurfaceMuted,
|
|
379
|
+
fontFamily: theme.typography.fontFamily,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
caption,
|
|
383
|
+
),
|
|
384
|
+
h(View, { style: [{ flex: 1 }, rule] }),
|
|
385
|
+
)
|
|
386
|
+
: h(View, { style: [{ width: "100%" }, rule] });
|
|
387
|
+
return h(View, { style: [root, style], testID }, body);
|
|
388
|
+
}
|
|
389
|
+
Divider.displayName = "Divider";
|
|
390
|
+
|
|
391
|
+
return { Badge, Avatar, Progress, Stat, Divider };
|
|
392
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `<Badge>`, `<Avatar>`, `<Progress>`, `<Stat>`, `<Divider>` (web). Binds
|
|
2
|
+
// ./detail-parts-view.js to react-native-web; see ./primitives.js for why not
|
|
3
|
+
// the bare specifier.
|
|
4
|
+
|
|
5
|
+
import * as ReactNative from "react-native-web";
|
|
6
|
+
import Svg, { Circle } from "react-native-svg";
|
|
7
|
+
import { makeDetailParts } from "./detail-parts-view.js";
|
|
8
|
+
|
|
9
|
+
export const { Badge, Avatar, Progress, Stat, Divider } = makeDetailParts({
|
|
10
|
+
View: ReactNative.View,
|
|
11
|
+
Text: ReactNative.Text,
|
|
12
|
+
Image: ReactNative.Image,
|
|
13
|
+
Svg,
|
|
14
|
+
Circle,
|
|
15
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `<Badge>`, `<Avatar>`, `<Progress>`, `<Stat>`, `<Divider>` (native). Binds
|
|
2
|
+
// ./detail-parts-view.js to React Native; only the imports differ from
|
|
3
|
+
// ./detail-parts.js.
|
|
4
|
+
|
|
5
|
+
import { View, Text, Image } from "react-native";
|
|
6
|
+
import Svg, { Circle } from "react-native-svg";
|
|
7
|
+
import { makeDetailParts } from "./detail-parts-view.js";
|
|
8
|
+
|
|
9
|
+
export const { Badge, Avatar, Progress, Stat, Divider } = makeDetailParts({
|
|
10
|
+
View,
|
|
11
|
+
Text,
|
|
12
|
+
Image,
|
|
13
|
+
Svg,
|
|
14
|
+
Circle,
|
|
15
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -160,9 +160,10 @@ export interface WidgetInputDescriptor {
|
|
|
160
160
|
* - At most 24 columns per table. Same reason.
|
|
161
161
|
*
|
|
162
162
|
* RELATION columns reference siblings within the same template via
|
|
163
|
-
* `targetSuffix` — that suffix MUST belong to a table
|
|
164
|
-
*
|
|
165
|
-
*
|
|
163
|
+
* `targetSuffix` — that suffix MUST belong to a table in the same array,
|
|
164
|
+
* declared anywhere in it; tables may relate to each other both ways.
|
|
165
|
+
* There is intentionally no way to reference a table outside the
|
|
166
|
+
* widget's own template.
|
|
166
167
|
*/
|
|
167
168
|
export interface WidgetDatastoreTemplateColumn {
|
|
168
169
|
name: string;
|
|
@@ -896,6 +897,13 @@ export interface DirectoryResult {
|
|
|
896
897
|
*/
|
|
897
898
|
export function useDirectory(query?: DirectoryQuery): DirectoryResult;
|
|
898
899
|
|
|
900
|
+
/**
|
|
901
|
+
* The emitter for an event the manifest declares. A sibling's wired input
|
|
902
|
+
* reads the payload, and a page action the author binds to the event (Run
|
|
903
|
+
* action, Navigate) runs with it — fired from one control, only that control
|
|
904
|
+
* runs it. Never declare an event named `onPress`: that binding makes the
|
|
905
|
+
* whole widget one tap target.
|
|
906
|
+
*/
|
|
899
907
|
export function useWidgetEvent(name: string): (payload?: unknown) => void;
|
|
900
908
|
|
|
901
909
|
/**
|
|
@@ -2601,7 +2609,48 @@ export const ListRow: (props: ListRowTokens & {
|
|
|
2601
2609
|
accessibilityLabel?: string;
|
|
2602
2610
|
style?: any;
|
|
2603
2611
|
testID?: string;
|
|
2604
|
-
/**
|
|
2612
|
+
/**
|
|
2613
|
+
* Replaces the title block, e.g. for a multi-column row. A single `View`
|
|
2614
|
+
* child spans the row between `leading` and `trailing`; anything else
|
|
2615
|
+
* (several cells, say) sits in the row as given.
|
|
2616
|
+
*/
|
|
2617
|
+
children?: ReactNode;
|
|
2618
|
+
}) => any;
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* A table's column labels. Shares `<ListRow>`'s horizontal padding, gap and
|
|
2622
|
+
* side edges (from the same Theme Settings > Lists values), so a label given
|
|
2623
|
+
* the same flex as its row cell sits exactly over that column. Pass it the
|
|
2624
|
+
* same overrides as the `<List>`. Make it the rows' `ScrollView`'s first,
|
|
2625
|
+
* sticky child so it shares their width beside a scrollbar.
|
|
2626
|
+
*
|
|
2627
|
+
* @example
|
|
2628
|
+
* <ScrollView stickyHeaderIndices={[0]}>
|
|
2629
|
+
* <ListHeader style={{ backgroundColor: theme.colors.surface }}>
|
|
2630
|
+
* <Text style={[overline, { flex: 2, minWidth: 0 }]}>Name</Text>
|
|
2631
|
+
* <Text style={[overline, { flex: 1, minWidth: 0 }]}>Status</Text>
|
|
2632
|
+
* </ListHeader>
|
|
2633
|
+
* <List>
|
|
2634
|
+
* {rows.map((r) => (
|
|
2635
|
+
* <ListRow key={r.id} onPress={() => open(r)}>
|
|
2636
|
+
* <Text numberOfLines={1} style={[body, { flex: 2, minWidth: 0 }]}>{r.name}</Text>
|
|
2637
|
+
* <Text numberOfLines={1} style={[body, { flex: 1, minWidth: 0 }]}>{r.status}</Text>
|
|
2638
|
+
* </ListRow>
|
|
2639
|
+
* ))}
|
|
2640
|
+
* </List>
|
|
2641
|
+
* </ScrollView>
|
|
2642
|
+
*/
|
|
2643
|
+
export const ListHeader: (props: Pick<ListRowTokens, "rowPadding" | "gap" | "dividerColor" | "dividerWidth"> & {
|
|
2644
|
+
/** A spacer as wide as the rows' `leading`, so the columns still line up. */
|
|
2645
|
+
leading?: ReactNode;
|
|
2646
|
+
/** A spacer (or label) as wide as the rows' `trailing`. */
|
|
2647
|
+
trailing?: ReactNode;
|
|
2648
|
+
/** The divider under the labels. Defaults to true. */
|
|
2649
|
+
divider?: boolean;
|
|
2650
|
+
accessibilityLabel?: string;
|
|
2651
|
+
style?: any;
|
|
2652
|
+
testID?: string;
|
|
2653
|
+
/** The column labels, shaped exactly like the rows' `children`. */
|
|
2605
2654
|
children?: ReactNode;
|
|
2606
2655
|
}) => any;
|
|
2607
2656
|
|
|
@@ -2616,6 +2665,82 @@ export const EmptyState: (props: {
|
|
|
2616
2665
|
children?: ReactNode;
|
|
2617
2666
|
}) => any;
|
|
2618
2667
|
|
|
2668
|
+
/**
|
|
2669
|
+
* Themed status pill: a record's state (Active, Paid, Overdue) beside the
|
|
2670
|
+
* thing it describes. The label always renders; the tone only tints it.
|
|
2671
|
+
*
|
|
2672
|
+
* @example
|
|
2673
|
+
* <Badge label={row.status} tone="success" />
|
|
2674
|
+
*/
|
|
2675
|
+
export const Badge: (props: {
|
|
2676
|
+
label?: string | number;
|
|
2677
|
+
tone?: "neutral" | "info" | "success" | "warning" | "danger";
|
|
2678
|
+
/** A lucide icon name shown before the label. */
|
|
2679
|
+
icon?: string;
|
|
2680
|
+
style?: any;
|
|
2681
|
+
testID?: string;
|
|
2682
|
+
}) => any;
|
|
2683
|
+
|
|
2684
|
+
/** Themed picture of a person, falling back to initials, then a glyph. */
|
|
2685
|
+
export const Avatar: (props: {
|
|
2686
|
+
/** Drives the initials and the accessibility label. */
|
|
2687
|
+
name?: string;
|
|
2688
|
+
/** An absolute image URL. */
|
|
2689
|
+
url?: string;
|
|
2690
|
+
/** A size token, or a number of px (clamped to 16-160). */
|
|
2691
|
+
size?: "sm" | "md" | "lg" | "xl" | number;
|
|
2692
|
+
shape?: "circle" | "rounded";
|
|
2693
|
+
accessibilityLabel?: string;
|
|
2694
|
+
style?: any;
|
|
2695
|
+
testID?: string;
|
|
2696
|
+
}) => any;
|
|
2697
|
+
|
|
2698
|
+
/** Themed completion bar or ring for real progress toward a known goal. */
|
|
2699
|
+
export const Progress: (props: {
|
|
2700
|
+
/** Percent complete; clamped to 0-100. */
|
|
2701
|
+
value?: number;
|
|
2702
|
+
variant?: "bar" | "ring";
|
|
2703
|
+
label?: string;
|
|
2704
|
+
/** Render the percent as text. Default true. */
|
|
2705
|
+
showValue?: boolean;
|
|
2706
|
+
tone?: "primary" | "success" | "warning" | "danger";
|
|
2707
|
+
style?: any;
|
|
2708
|
+
testID?: string;
|
|
2709
|
+
}) => any;
|
|
2710
|
+
|
|
2711
|
+
/** Themed headline figure. Displays a value you computed; it fetches nothing. */
|
|
2712
|
+
export const Stat: (props: {
|
|
2713
|
+
/** The figure. Empty (null, blank, NaN) prints `emptyValue` instead. */
|
|
2714
|
+
value?: string | number | null;
|
|
2715
|
+
label?: string;
|
|
2716
|
+
/** A lucide icon name beside the figure. */
|
|
2717
|
+
icon?: string;
|
|
2718
|
+
delta?: string;
|
|
2719
|
+
deltaTone?: "neutral" | "positive" | "negative";
|
|
2720
|
+
/** Printed when `value` is empty. Default "—". */
|
|
2721
|
+
emptyValue?: string;
|
|
2722
|
+
color?: string;
|
|
2723
|
+
/** The figure's size in px (12-96); defaults to the theme's xxl. */
|
|
2724
|
+
fontSize?: number;
|
|
2725
|
+
style?: any;
|
|
2726
|
+
testID?: string;
|
|
2727
|
+
}) => any;
|
|
2728
|
+
|
|
2729
|
+
/** Themed rule splitting two groups inside one surface; optionally captioned. */
|
|
2730
|
+
export const Divider: (props: {
|
|
2731
|
+
label?: string;
|
|
2732
|
+
/** Line thickness in px, 1-8. */
|
|
2733
|
+
thickness?: number;
|
|
2734
|
+
spacing?: "sm" | "md" | "lg";
|
|
2735
|
+
/** Defaults to the theme's border colour. */
|
|
2736
|
+
color?: string;
|
|
2737
|
+
style?: any;
|
|
2738
|
+
testID?: string;
|
|
2739
|
+
}) => any;
|
|
2740
|
+
|
|
2741
|
+
/** A person's initials: "Ada Lovelace" → "AL"; "" when the name has no letters. */
|
|
2742
|
+
export function initialsFor(name: unknown): string;
|
|
2743
|
+
|
|
2619
2744
|
/**
|
|
2620
2745
|
* Themed card surface. With no props it takes the app's card look (Theme
|
|
2621
2746
|
* Settings > Cards). Props override one card; `style` takes the `card*` style
|
package/dist/index.js
CHANGED
|
@@ -196,7 +196,10 @@ export {
|
|
|
196
196
|
} from "./emoji-reactions.js";
|
|
197
197
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
198
198
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
199
|
-
export { List, ListRow, EmptyState } from "./list.js";
|
|
199
|
+
export { List, ListRow, ListHeader, EmptyState } from "./list.js";
|
|
200
|
+
// Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
|
|
201
|
+
export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.js";
|
|
202
|
+
export { initialsFor } from "./initials.js";
|
|
200
203
|
// The one card surface: the theme's `card` scope, a gradient fill, margin.
|
|
201
204
|
export { Card } from "./card.js";
|
|
202
205
|
export {
|
package/dist/index.native.js
CHANGED
|
@@ -189,7 +189,10 @@ export {
|
|
|
189
189
|
} from "./emoji-reactions.js";
|
|
190
190
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
191
191
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
192
|
-
export { List, ListRow, EmptyState } from "./list.native.js";
|
|
192
|
+
export { List, ListRow, ListHeader, EmptyState } from "./list.native.js";
|
|
193
|
+
// Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
|
|
194
|
+
export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.native.js";
|
|
195
|
+
export { initialsFor } from "./initials.js";
|
|
193
196
|
// The one card surface: the theme's `card` scope, a gradient fill, margin.
|
|
194
197
|
export { Card } from "./card.native.js";
|
|
195
198
|
export {
|
package/dist/initials.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The ONE initials rule, for `<Avatar>` and every built-in that falls back to a
|
|
2
|
+
// person's initials. Two rules would print different letters for one person.
|
|
3
|
+
//
|
|
4
|
+
// Returns "" when the name yields no letters, so each caller keeps its own
|
|
5
|
+
// fallback (a "?", a person glyph).
|
|
6
|
+
|
|
7
|
+
/** "Ada Lovelace" → "AL", "Ada" → "A", " " → "". */
|
|
8
|
+
export function initialsFor(name) {
|
|
9
|
+
const parts =
|
|
10
|
+
typeof name === "string" ? name.trim().split(/\s+/).filter(Boolean) : [];
|
|
11
|
+
if (parts.length === 0) return "";
|
|
12
|
+
// Array.from, not slice: a multi-byte first character must not be halved.
|
|
13
|
+
const first = Array.from(parts[0])[0];
|
|
14
|
+
if (parts.length === 1) return first.toUpperCase();
|
|
15
|
+
const last = Array.from(parts[parts.length - 1])[0];
|
|
16
|
+
return (first + last).toUpperCase();
|
|
17
|
+
}
|
package/dist/list-tokens.js
CHANGED
|
@@ -9,6 +9,9 @@ import { normaliseThemeComponents } from "./theme-components.js";
|
|
|
9
9
|
// A row with no themed height still meets the 44px touch-target floor.
|
|
10
10
|
export const LIST_ROW_MIN_HEIGHT = 44;
|
|
11
11
|
|
|
12
|
+
// The accent bar a selected row carries on its leading edge.
|
|
13
|
+
export const LIST_SELECTED_BAR_WIDTH = 3;
|
|
14
|
+
|
|
12
15
|
// The per-row overrides a widget may pass, named as the theme tokens are.
|
|
13
16
|
export const LIST_TOKEN_NAMES = Object.freeze([
|
|
14
17
|
"rowPadding",
|
|
@@ -66,3 +69,31 @@ export function resolveListTokens(theme, overrides) {
|
|
|
66
69
|
pressedBackground: mixHex(colors.onSurface, background || colors.surface, 0.94),
|
|
67
70
|
};
|
|
68
71
|
}
|
|
72
|
+
|
|
73
|
+
/** The width of a row's side edges: a tile's outline, none on a flush band. */
|
|
74
|
+
export function listSideEdgeWidth(tokens) {
|
|
75
|
+
return tokens.separated ? tokens.dividerWidth : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The horizontal geometry `<ListRow>` and `<ListHeader>` share: the direction,
|
|
80
|
+
* the gap between leading, body and trailing, and the inset. Both draw from
|
|
81
|
+
* this one function, so a header cell sits over its row cell by construction.
|
|
82
|
+
*
|
|
83
|
+
* @param {ReturnType<typeof resolveListTokens>} tokens
|
|
84
|
+
* @param {{ md: number }} spacing — the theme's spacing scale.
|
|
85
|
+
* @param {boolean} [selected] — the row carries the accent bar.
|
|
86
|
+
*/
|
|
87
|
+
export function listRowFrame(tokens, spacing, selected = false) {
|
|
88
|
+
const frame = {
|
|
89
|
+
flexDirection: "row",
|
|
90
|
+
alignItems: "center",
|
|
91
|
+
gap: spacing.md,
|
|
92
|
+
paddingHorizontal: tokens.rowPadding,
|
|
93
|
+
};
|
|
94
|
+
if (!selected) return frame;
|
|
95
|
+
// The bar replaces the side edge and takes its width out of the inset,
|
|
96
|
+
// so a row's cells do not move when it is selected.
|
|
97
|
+
const inset = tokens.rowPadding + listSideEdgeWidth(tokens) - LIST_SELECTED_BAR_WIDTH;
|
|
98
|
+
return { ...frame, paddingLeft: Math.max(0, inset) };
|
|
99
|
+
}
|