@twentyfirstmedia/ui-core 0.7.0 → 0.8.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.
Files changed (54) hide show
  1. package/ICONS-LICENSE +24 -2
  2. package/dist/components/connected-data.d.ts +25 -0
  3. package/dist/components/connected-data.d.ts.map +1 -0
  4. package/dist/components/connected-data.js +264 -0
  5. package/dist/components/connected-data.js.map +1 -0
  6. package/dist/components/connectors.d.ts +22 -0
  7. package/dist/components/connectors.d.ts.map +1 -0
  8. package/dist/components/connectors.js +135 -0
  9. package/dist/components/connectors.js.map +1 -0
  10. package/dist/components/index.d.ts +2 -0
  11. package/dist/components/index.d.ts.map +1 -1
  12. package/dist/components/index.js +2 -0
  13. package/dist/components/index.js.map +1 -1
  14. package/dist/components/life.d.ts.map +1 -1
  15. package/dist/components/life.js +3 -2
  16. package/dist/components/life.js.map +1 -1
  17. package/dist/components/types.d.ts +290 -0
  18. package/dist/components/types.d.ts.map +1 -1
  19. package/dist/context.d.ts +1 -0
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +2 -1
  22. package/dist/context.js.map +1 -1
  23. package/dist/format.d.ts +5 -0
  24. package/dist/format.d.ts.map +1 -1
  25. package/dist/format.js +31 -0
  26. package/dist/format.js.map +1 -1
  27. package/dist/icon-library.d.ts +235 -4
  28. package/dist/icon-library.d.ts.map +1 -1
  29. package/dist/icon-library.js +467 -15
  30. package/dist/icon-library.js.map +1 -1
  31. package/dist/icons.d.ts +2 -0
  32. package/dist/icons.d.ts.map +1 -1
  33. package/dist/icons.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/strings.d.ts +89 -1
  39. package/dist/strings.d.ts.map +1 -1
  40. package/dist/strings.js +110 -1
  41. package/dist/strings.js.map +1 -1
  42. package/dist/themes.css +14447 -12207
  43. package/package.json +1 -1
  44. package/src/components/connected-data.tsx +783 -0
  45. package/src/components/connectors.tsx +426 -0
  46. package/src/components/index.ts +17 -0
  47. package/src/components/life.tsx +8 -1
  48. package/src/components/types.ts +280 -0
  49. package/src/context.ts +10 -1
  50. package/src/format.ts +33 -0
  51. package/src/icon-library.ts +476 -19
  52. package/src/icons.ts +2 -0
  53. package/src/index.ts +3 -0
  54. package/src/strings.ts +277 -0
@@ -0,0 +1,426 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { cx, initials } from "../cx.js";
4
+ import { useFormat, useStrings } from "../context.js";
5
+ import { CONNECTOR_STRINGS } from "../strings.js";
6
+ import { Icon } from "../Icon.js";
7
+ import { iconData, registerIcons } from "../icons.js";
8
+ import { refreshCwOff } from "../icon-library.js";
9
+ import { Badge, Button, Chip } from "./foundation.js";
10
+ import { SearchField } from "./inputs.js";
11
+ import { EmptyState } from "./feedback.js";
12
+ import type {
13
+ ConnectionListProps,
14
+ AgentActivityProps,
15
+ AgentAction,
16
+ ConnectorGalleryProps,
17
+ Connector,
18
+ ServiceMarkProps,
19
+ SyncStatusProps,
20
+ } from "./types.js";
21
+
22
+ // default icons of these components come from the icon library
23
+ registerIcons({ refreshCwOff });
24
+
25
+ const MARK_SIZE = { sm: 14, md: 20, lg: 26 } as const;
26
+
27
+ function isWide(name: string): boolean {
28
+ const d = iconData(name);
29
+ return typeof d === "object" && d.wide === true;
30
+ }
31
+
32
+ /**
33
+ * A service's mark: its brand logo, your own logo, or a monogram when neither exists, with an
34
+ * optional state dot. Use it wherever a connected service appears (lists, chips, activity).
35
+ */
36
+ export function ServiceMark(p: ServiceMarkProps) {
37
+ const t = useStrings(CONNECTOR_STRINGS);
38
+ const size = p.size || "md";
39
+ const label = p.status ? `${p.name}, ${t.serviceStates[p.status]}` : p.name;
40
+ return (
41
+ <span
42
+ style={p.style}
43
+ className={cx(
44
+ "tf-svc",
45
+ "tf-svc-" + size,
46
+ "tf-t-" + (p.tint || "plain"),
47
+ !p.icon && !p.logo && "is-monogram",
48
+ p.className,
49
+ )}
50
+ role={p.decorative ? undefined : "img"}
51
+ aria-label={p.decorative ? undefined : label}
52
+ aria-hidden={p.decorative ? true : undefined}
53
+ >
54
+ {p.logo ||
55
+ (p.icon ? (
56
+ isWide(p.icon) ? (
57
+ <Icon name={p.icon} style={{ width: "88%", height: "88%" }} />
58
+ ) : (
59
+ <Icon name={p.icon} size={MARK_SIZE[size]} />
60
+ )
61
+ ) : (
62
+ <span className="tf-svc-mono">{(p.monogram || initials(p.name)).slice(0, 2)}</span>
63
+ ))}
64
+ {p.status && <span className={cx("tf-svc-dot", "is-" + p.status)} />}
65
+ </span>
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Every service an assistant can connect to, grouped by category, with search and category chips.
71
+ * Each tile says whether the service is connected, needs a reconnect or comes later.
72
+ */
73
+ export function ConnectorGallery(p: ConnectorGalleryProps) {
74
+ const t = useStrings(CONNECTOR_STRINGS);
75
+ const [query, setQuery] = React.useState("");
76
+ const [category, setCategory] = React.useState<string | null>(null);
77
+ const order = React.useMemo(() => {
78
+ const seen = [...(p.categories || [])];
79
+ for (const c of p.connectors) if (!seen.includes(c.category)) seen.push(c.category);
80
+ return seen.filter((c) => p.connectors.some((x) => x.category === c));
81
+ }, [p.connectors, p.categories]);
82
+ const q = query.trim().toLowerCase();
83
+ const shown = p.connectors.filter(
84
+ (c) =>
85
+ (!category || c.category === category) &&
86
+ (!q || c.name.toLowerCase().includes(q) || c.category.toLowerCase().includes(q)),
87
+ );
88
+ const filters = p.filters !== false;
89
+ return (
90
+ <div style={p.style} className={cx("tf-connectors", p.className)}>
91
+ {filters && (
92
+ <div className="tf-connectors-filters">
93
+ <SearchField
94
+ label={t.searchConnectors}
95
+ placeholder={t.searchConnectors}
96
+ value={query}
97
+ onChange={(e) => setQuery(e.currentTarget.value)}
98
+ />
99
+ <div className="tf-connectors-chips" role="group" aria-label={t.connectors}>
100
+ <Chip selected={!category} onClick={() => setCategory(null)}>
101
+ {t.allCategories}
102
+ </Chip>
103
+ {order.map((c) => (
104
+ <Chip key={c} selected={category === c} onClick={() => setCategory(c)}>
105
+ {c}
106
+ </Chip>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ )}
111
+ {shown.length === 0 ? (
112
+ <EmptyState icon="search" title={t.noConnectorMatch(query.trim())} />
113
+ ) : (
114
+ order
115
+ .filter((c) => shown.some((x) => x.category === c))
116
+ .map((c) => (
117
+ <section key={c} className="tf-connectors-group" aria-label={c}>
118
+ <h3 className="tf-connectors-title">{c}</h3>
119
+ <ul className="tf-connectors-grid">
120
+ {shown
121
+ .filter((x) => x.category === c)
122
+ .map((x) => (
123
+ <ConnectorTile key={x.id} c={x} onConnect={p.onConnect} onOpen={p.onOpen} />
124
+ ))}
125
+ </ul>
126
+ </section>
127
+ ))
128
+ )}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function ConnectorTile({
134
+ c,
135
+ onConnect,
136
+ onOpen,
137
+ }: {
138
+ c: Connector;
139
+ onConnect?: (id: string) => void;
140
+ onOpen?: (id: string) => void;
141
+ }) {
142
+ const t = useStrings(CONNECTOR_STRINGS);
143
+ const state = c.state || "available";
144
+ return (
145
+ <li className={cx("tf-connector", "is-" + state)}>
146
+ <div className="tf-connector-head">
147
+ <ServiceMark
148
+ name={c.name}
149
+ icon={c.icon}
150
+ logo={c.logo}
151
+ monogram={c.monogram}
152
+ tint={c.tint}
153
+ status={state === "connected" ? "connected" : state === "error" ? "error" : undefined}
154
+ decorative
155
+ />
156
+ <span className="tf-option-text">
157
+ <b>{c.name}</b>
158
+ {c.account && <small>{c.account}</small>}
159
+ </span>
160
+ </div>
161
+ {c.description && <p className="tf-connector-desc">{c.description}</p>}
162
+ {c.note && <p className="tf-connector-note">{c.note}</p>}
163
+ <div className="tf-connector-foot">
164
+ {state === "connected" && (
165
+ <>
166
+ <Badge tone="success" dot>
167
+ {t.serviceStates.connected}
168
+ </Badge>
169
+ {onOpen && (
170
+ <Button
171
+ variant="ghost"
172
+ size="sm"
173
+ aria-label={t.openService(c.name)}
174
+ onClick={() => onOpen(c.id)}
175
+ >
176
+ {t.openAction}
177
+ </Button>
178
+ )}
179
+ </>
180
+ )}
181
+ {state === "error" && (
182
+ <>
183
+ <Badge tone="danger" dot>
184
+ {t.serviceStates.error}
185
+ </Badge>
186
+ <Button
187
+ variant="secondary"
188
+ size="sm"
189
+ aria-label={t.reconnectService(c.name)}
190
+ onClick={() => (onOpen || onConnect)?.(c.id)}
191
+ >
192
+ {t.reconnect}
193
+ </Button>
194
+ </>
195
+ )}
196
+ {state === "soon" && <Badge tone="neutral">{t.comingSoon}</Badge>}
197
+ {state === "available" && (
198
+ <Button
199
+ variant="secondary"
200
+ size="sm"
201
+ icon="plus"
202
+ aria-label={t.connectService(c.name)}
203
+ onClick={() => onConnect?.(c.id)}
204
+ >
205
+ {t.connect}
206
+ </Button>
207
+ )}
208
+ </div>
209
+ </li>
210
+ );
211
+ }
212
+
213
+ /** When a connected service last synced, what it brought and when it syncs next. */
214
+ export function SyncStatus(p: SyncStatusProps) {
215
+ const t = useStrings(CONNECTOR_STRINGS);
216
+ const { formatRelative, formatNumber } = useFormat();
217
+ const now = p.now ?? Date.now();
218
+ const text =
219
+ p.state === "syncing"
220
+ ? t.syncingNow
221
+ : p.state === "error"
222
+ ? t.syncFailed
223
+ : p.state === "paused"
224
+ ? t.syncPaused
225
+ : p.state === "never" || p.last == null
226
+ ? t.neverSynced
227
+ : t.syncedAgo(formatRelative(p.last, now));
228
+ const icon =
229
+ p.state === "error"
230
+ ? "x"
231
+ : p.state === "paused"
232
+ ? "pause"
233
+ : p.state === "never"
234
+ ? "refreshCwOff"
235
+ : p.state === "syncing"
236
+ ? "refresh"
237
+ : "check";
238
+ return (
239
+ <div
240
+ style={p.style}
241
+ className={cx("tf-sync", "is-" + p.state, p.compact && "compact", p.className)}
242
+ role="status"
243
+ >
244
+ <div className="tf-sync-line">
245
+ <span className="tf-sync-icon" aria-hidden>
246
+ <Icon name={icon} size={14} />
247
+ </span>
248
+ <span className="tf-sync-text">
249
+ {text}
250
+ {p.state === "error" && p.last != null && !p.compact && (
251
+ <small> · {t.syncedAgo(formatRelative(p.last, now))}</small>
252
+ )}
253
+ </span>
254
+ {!p.compact && p.next != null && p.state !== "paused" && p.state !== "syncing" && (
255
+ <small className="tf-sync-next">{t.nextSync(formatRelative(p.next, now))}</small>
256
+ )}
257
+ {p.onSync && p.state !== "syncing" && (
258
+ <Button variant="ghost" size="sm" icon="refresh" onClick={p.onSync}>
259
+ {t.syncNow}
260
+ </Button>
261
+ )}
262
+ </div>
263
+ {!p.compact && p.error && <p className="tf-sync-error">{p.error}</p>}
264
+ {!p.compact && p.counts && p.counts.length > 0 && (
265
+ <ul className="tf-sync-counts">
266
+ {p.counts.map((c) => (
267
+ <li key={c.label}>
268
+ <b>{formatNumber(c.value)}</b> {c.label}
269
+ </li>
270
+ ))}
271
+ </ul>
272
+ )}
273
+ </div>
274
+ );
275
+ }
276
+
277
+ const DAY = 86400000;
278
+ function dayStart(v: Date | string | number): number {
279
+ const d = new Date(v);
280
+ return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
281
+ }
282
+
283
+ /**
284
+ * What agents did with your connected services, newest first and grouped by day: what they read,
285
+ * what they changed and what waits for your approval, with "Undo" where it can be undone.
286
+ */
287
+ export function AgentActivity(p: AgentActivityProps) {
288
+ const t = useStrings(CONNECTOR_STRINGS);
289
+ const { formatDate, locale } = useFormat();
290
+ const now = p.now ?? Date.now();
291
+ const sorted = [...p.items].sort((a, b) => +new Date(b.at) - +new Date(a.at));
292
+ const groups: { key: number; items: AgentAction[] }[] = [];
293
+ for (const it of sorted) {
294
+ const k = dayStart(it.at);
295
+ const last = groups[groups.length - 1];
296
+ if (last && last.key === k) last.items.push(it);
297
+ else groups.push({ key: k, items: [it] });
298
+ }
299
+ const heading = (k: number, sample: AgentAction["at"]) => {
300
+ const diff = Math.round((k - dayStart(now)) / DAY);
301
+ if (diff === 0 || diff === -1) {
302
+ try {
303
+ const w = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(diff, "day");
304
+ return w.charAt(0).toLocaleUpperCase(locale) + w.slice(1);
305
+ } catch {
306
+ /* fall through to the date */
307
+ }
308
+ }
309
+ return formatDate(sample, { weekday: "long", day: "numeric", month: "long" });
310
+ };
311
+ return (
312
+ <div style={p.style} className={cx("tf-agentlog", p.className)}>
313
+ {groups.map((g) => (
314
+ <section
315
+ key={g.key}
316
+ className="tf-agentlog-day"
317
+ aria-label={heading(g.key, g.items[0]!.at)}
318
+ >
319
+ <h3 className="tf-agentlog-title" aria-hidden>
320
+ {heading(g.key, g.items[0]!.at)}
321
+ </h3>
322
+ <ul aria-label={p.label || t.agentActivity}>
323
+ {g.items.map((a) => {
324
+ const state = a.state || "done";
325
+ const kind = a.kind || "read";
326
+ return (
327
+ <li key={a.id} className={cx("tf-agentlog-item", "is-" + state, "kind-" + kind)}>
328
+ {a.service ? (
329
+ <ServiceMark
330
+ name={a.service.name}
331
+ icon={a.service.icon}
332
+ logo={a.service.logo}
333
+ size="sm"
334
+ />
335
+ ) : (
336
+ <span className="tf-svc tf-svc-sm tf-t-plain" aria-hidden>
337
+ <Icon name="bot" size={14} />
338
+ </span>
339
+ )}
340
+ <div className="tf-agentlog-main">
341
+ <p className="tf-agentlog-text">{a.text}</p>
342
+ {a.detail && <p className="tf-agentlog-detail">{a.detail}</p>}
343
+ <p className="tf-agentlog-meta">
344
+ {[a.agent, formatDate(a.at, { hour: "numeric", minute: "2-digit" })]
345
+ .filter(Boolean)
346
+ .join(" · ")}
347
+ <span className={cx("tf-agentlog-kind", "kind-" + kind)}>
348
+ {t.actionKinds[kind]}
349
+ </span>
350
+ {state !== "done" && (
351
+ <span className={cx("tf-agentlog-state", "is-" + state)}>
352
+ {t.actionStates[state]}
353
+ </span>
354
+ )}
355
+ </p>
356
+ </div>
357
+ {state === "waiting" && p.onReview && (
358
+ <Button variant="primary" size="sm" onClick={() => p.onReview?.(a.id)}>
359
+ {t.reviewAction}
360
+ </Button>
361
+ )}
362
+ {state === "done" && kind !== "read" && p.onUndo && (
363
+ <Button variant="ghost" size="sm" icon="undo" onClick={() => p.onUndo?.(a.id)}>
364
+ {t.undo}
365
+ </Button>
366
+ )}
367
+ </li>
368
+ );
369
+ })}
370
+ </ul>
371
+ </section>
372
+ ))}
373
+ </div>
374
+ );
375
+ }
376
+
377
+ /** The services you are connected to: account, state, how fresh the data is, and Manage. */
378
+ export function ConnectionList(p: ConnectionListProps) {
379
+ const t = useStrings(CONNECTOR_STRINGS);
380
+ return (
381
+ <ul
382
+ style={p.style}
383
+ className={cx("tf-connlist", p.className)}
384
+ aria-label={p.label || t.connectionsLabel}
385
+ >
386
+ {p.items.map((c) => (
387
+ <li key={c.id} className={cx("tf-connlist-item", "is-" + c.state)}>
388
+ <ServiceMark
389
+ name={c.name}
390
+ icon={c.icon}
391
+ logo={c.logo}
392
+ monogram={c.monogram}
393
+ tint={c.tint}
394
+ status={c.state}
395
+ />
396
+ <span className="tf-option-text">
397
+ <b>{c.name}</b>
398
+ {c.account && <small>{c.account}</small>}
399
+ </span>
400
+ {c.sync && <SyncStatus state={c.sync.state} last={c.sync.last} now={p.now} compact />}
401
+ {c.state === "error" && p.onReconnect ? (
402
+ <Button
403
+ variant="secondary"
404
+ size="sm"
405
+ aria-label={t.reconnectService(c.name)}
406
+ onClick={() => p.onReconnect?.(c.id)}
407
+ >
408
+ {t.reconnect}
409
+ </Button>
410
+ ) : (
411
+ p.onManage && (
412
+ <Button
413
+ variant="ghost"
414
+ size="sm"
415
+ aria-label={t.manageService(c.name)}
416
+ onClick={() => p.onManage?.(c.id)}
417
+ >
418
+ {t.manageAction}
419
+ </Button>
420
+ )
421
+ )}
422
+ </li>
423
+ ))}
424
+ </ul>
425
+ );
426
+ }
@@ -44,6 +44,23 @@ export {
44
44
  ClaimTimeline,
45
45
  DocumentList,
46
46
  } from "./household.js";
47
+ export {
48
+ ServiceMark,
49
+ ConnectorGallery,
50
+ ConnectionList,
51
+ SyncStatus,
52
+ AgentActivity,
53
+ } from "./connectors.js";
54
+ export {
55
+ DeviceTile,
56
+ VehicleCard,
57
+ NowPlaying,
58
+ WorkoutCard,
59
+ CallLog,
60
+ ReservationCard,
61
+ Scorecard,
62
+ HoldingsList,
63
+ } from "./connected-data.js";
47
64
  export {
48
65
  AgentCard,
49
66
  AgentDetails,
@@ -20,6 +20,7 @@ import { useControlled, useId } from "../hooks.js";
20
20
  import { Badge, Button, IconButton, TintIcon } from "./foundation.js";
21
21
  import { Sparkline } from "./charts.js";
22
22
  import { Switch } from "./inputs.js";
23
+ import { ServiceMark } from "./connectors.js";
23
24
  import type {
24
25
  ActivityRingsProps,
25
26
  ConsentListProps,
@@ -630,7 +631,7 @@ export function ConsentList(p: ConsentListProps) {
630
631
  aria-label={c.name}
631
632
  >
632
633
  <div className="tf-consent-head">
633
- {c.logo || <TintIcon icon={c.icon || "plug"} tint="sky" size={36} />}
634
+ <ServiceMark name={c.name} icon={c.icon} logo={c.logo} tint="sky" decorative />
634
635
  <div className="tf-option-text">
635
636
  <b>{c.name}</b>
636
637
  <small>
@@ -652,6 +653,12 @@ export function ConsentList(p: ConsentListProps) {
652
653
  <span className="tf-option-text">
653
654
  <b>{s.label}</b>
654
655
  {s.description && <small>{s.description}</small>}
656
+ {s.level && (
657
+ <span className={cx("tf-scope-level", "is-" + s.level)}>
658
+ {t.scopeLevels[s.level]}
659
+ {s.asksFirst && " · " + t.asksFirst}
660
+ </span>
661
+ )}
655
662
  </span>
656
663
  {s.required ? (
657
664
  <span className="tf-consent-req">{t.requiredScope}</span>