@twentyfirstmedia/ui-core 0.5.0 → 0.7.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 (163) hide show
  1. package/dist/components/admin.d.ts.map +1 -1
  2. package/dist/components/admin.js +31 -21
  3. package/dist/components/admin.js.map +1 -1
  4. package/dist/components/agents.d.ts.map +1 -1
  5. package/dist/components/agents.js +21 -18
  6. package/dist/components/agents.js.map +1 -1
  7. package/dist/components/app-chrome.d.ts.map +1 -1
  8. package/dist/components/app-chrome.js +5 -5
  9. package/dist/components/app-chrome.js.map +1 -1
  10. package/dist/components/approvals.d.ts.map +1 -1
  11. package/dist/components/approvals.js +2 -2
  12. package/dist/components/approvals.js.map +1 -1
  13. package/dist/components/building-blocks.d.ts.map +1 -1
  14. package/dist/components/building-blocks.js +13 -10
  15. package/dist/components/building-blocks.js.map +1 -1
  16. package/dist/components/charts.d.ts +2 -2
  17. package/dist/components/charts.d.ts.map +1 -1
  18. package/dist/components/charts.js +14 -11
  19. package/dist/components/charts.js.map +1 -1
  20. package/dist/components/chat.d.ts +2 -2
  21. package/dist/components/chat.d.ts.map +1 -1
  22. package/dist/components/chat.js +22 -18
  23. package/dist/components/chat.js.map +1 -1
  24. package/dist/components/commerce.d.ts.map +1 -1
  25. package/dist/components/commerce.js +20 -16
  26. package/dist/components/commerce.js.map +1 -1
  27. package/dist/components/data-display.d.ts +0 -5
  28. package/dist/components/data-display.d.ts.map +1 -1
  29. package/dist/components/data-display.js +41 -22
  30. package/dist/components/data-display.js.map +1 -1
  31. package/dist/components/data-table.d.ts +2 -2
  32. package/dist/components/data-table.d.ts.map +1 -1
  33. package/dist/components/data-table.js +3 -3
  34. package/dist/components/data-table.js.map +1 -1
  35. package/dist/components/developer.d.ts.map +1 -1
  36. package/dist/components/developer.js +7 -6
  37. package/dist/components/developer.js.map +1 -1
  38. package/dist/components/feedback.d.ts.map +1 -1
  39. package/dist/components/feedback.js +5 -4
  40. package/dist/components/feedback.js.map +1 -1
  41. package/dist/components/forms.d.ts.map +1 -1
  42. package/dist/components/forms.js +63 -21
  43. package/dist/components/forms.js.map +1 -1
  44. package/dist/components/foundation.d.ts.map +1 -1
  45. package/dist/components/foundation.js +13 -9
  46. package/dist/components/foundation.js.map +1 -1
  47. package/dist/components/household.d.ts +19 -0
  48. package/dist/components/household.d.ts.map +1 -0
  49. package/dist/components/household.js +137 -0
  50. package/dist/components/household.js.map +1 -0
  51. package/dist/components/index.d.ts +4 -1
  52. package/dist/components/index.d.ts.map +1 -1
  53. package/dist/components/index.js +4 -1
  54. package/dist/components/index.js.map +1 -1
  55. package/dist/components/inputs.d.ts.map +1 -1
  56. package/dist/components/inputs.js +33 -8
  57. package/dist/components/inputs.js.map +1 -1
  58. package/dist/components/life.d.ts +26 -0
  59. package/dist/components/life.d.ts.map +1 -0
  60. package/dist/components/life.js +186 -0
  61. package/dist/components/life.js.map +1 -0
  62. package/dist/components/mail.d.ts.map +1 -1
  63. package/dist/components/mail.js +5 -5
  64. package/dist/components/mail.js.map +1 -1
  65. package/dist/components/media.d.ts.map +1 -1
  66. package/dist/components/media.js +62 -11
  67. package/dist/components/media.js.map +1 -1
  68. package/dist/components/navigation.d.ts +3 -1
  69. package/dist/components/navigation.d.ts.map +1 -1
  70. package/dist/components/navigation.js +287 -20
  71. package/dist/components/navigation.js.map +1 -1
  72. package/dist/components/operations.d.ts.map +1 -1
  73. package/dist/components/operations.js +10 -10
  74. package/dist/components/operations.js.map +1 -1
  75. package/dist/components/overlays.d.ts.map +1 -1
  76. package/dist/components/overlays.js +2 -2
  77. package/dist/components/overlays.js.map +1 -1
  78. package/dist/components/previews.js +2 -2
  79. package/dist/components/previews.js.map +1 -1
  80. package/dist/components/primitives.js +2 -2
  81. package/dist/components/primitives.js.map +1 -1
  82. package/dist/components/qr-code.d.ts +8 -0
  83. package/dist/components/qr-code.d.ts.map +1 -0
  84. package/dist/components/qr-code.js +18 -0
  85. package/dist/components/qr-code.js.map +1 -0
  86. package/dist/components/real-estate.d.ts.map +1 -1
  87. package/dist/components/real-estate.js +5 -5
  88. package/dist/components/real-estate.js.map +1 -1
  89. package/dist/components/rich-text-editor.d.ts.map +1 -1
  90. package/dist/components/rich-text-editor.js +1 -1
  91. package/dist/components/rich-text-editor.js.map +1 -1
  92. package/dist/components/select-menu.js +1 -1
  93. package/dist/components/select-menu.js.map +1 -1
  94. package/dist/components/shell.d.ts +0 -4
  95. package/dist/components/shell.d.ts.map +1 -1
  96. package/dist/components/shell.js +100 -7
  97. package/dist/components/shell.js.map +1 -1
  98. package/dist/components/site.d.ts.map +1 -1
  99. package/dist/components/site.js +8 -6
  100. package/dist/components/site.js.map +1 -1
  101. package/dist/components/status.d.ts.map +1 -1
  102. package/dist/components/status.js +2 -2
  103. package/dist/components/status.js.map +1 -1
  104. package/dist/components/system.d.ts.map +1 -1
  105. package/dist/components/system.js +3 -2
  106. package/dist/components/system.js.map +1 -1
  107. package/dist/components/types.d.ts +589 -18
  108. package/dist/components/types.d.ts.map +1 -1
  109. package/dist/components/workspace.d.ts.map +1 -1
  110. package/dist/components/workspace.js +15 -12
  111. package/dist/components/workspace.js.map +1 -1
  112. package/dist/index.d.ts +1 -1
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +1 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/qr.d.ts +14 -0
  117. package/dist/qr.d.ts.map +1 -0
  118. package/dist/qr.js +298 -0
  119. package/dist/qr.js.map +1 -0
  120. package/dist/strings.d.ts +126 -4
  121. package/dist/strings.d.ts.map +1 -1
  122. package/dist/strings.js +88 -1
  123. package/dist/strings.js.map +1 -1
  124. package/dist/themes.css +15592 -10463
  125. package/package.json +2 -1
  126. package/src/components/admin.tsx +66 -23
  127. package/src/components/agents.tsx +22 -20
  128. package/src/components/app-chrome.tsx +9 -5
  129. package/src/components/approvals.tsx +3 -1
  130. package/src/components/building-blocks.tsx +24 -13
  131. package/src/components/charts.tsx +17 -12
  132. package/src/components/chat.tsx +54 -31
  133. package/src/components/commerce.tsx +58 -24
  134. package/src/components/data-display.tsx +106 -38
  135. package/src/components/data-table.tsx +11 -7
  136. package/src/components/developer.tsx +15 -6
  137. package/src/components/feedback.tsx +5 -3
  138. package/src/components/forms.tsx +101 -23
  139. package/src/components/foundation.tsx +9 -5
  140. package/src/components/household.tsx +489 -0
  141. package/src/components/index.ts +32 -1
  142. package/src/components/inputs.tsx +44 -7
  143. package/src/components/life.tsx +672 -0
  144. package/src/components/mail.tsx +7 -3
  145. package/src/components/media.tsx +88 -16
  146. package/src/components/navigation.tsx +651 -71
  147. package/src/components/operations.tsx +17 -8
  148. package/src/components/overlays.tsx +7 -2
  149. package/src/components/previews.tsx +2 -2
  150. package/src/components/primitives.tsx +2 -2
  151. package/src/components/qr-code.tsx +30 -0
  152. package/src/components/real-estate.tsx +7 -4
  153. package/src/components/rich-text-editor.tsx +7 -1
  154. package/src/components/select-menu.tsx +1 -1
  155. package/src/components/shell.tsx +237 -11
  156. package/src/components/site.tsx +18 -4
  157. package/src/components/status.tsx +2 -0
  158. package/src/components/system.tsx +3 -2
  159. package/src/components/types.ts +583 -18
  160. package/src/components/workspace.tsx +37 -15
  161. package/src/index.ts +2 -0
  162. package/src/qr.ts +300 -0
  163. package/src/strings.ts +297 -1
@@ -0,0 +1,489 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { cx } from "../cx.js";
4
+ import { useFormat, useStrings } from "../context.js";
5
+ import { HOUSEHOLD_STRINGS } from "../strings.js";
6
+ import { Icon } from "../Icon.js";
7
+ import { registerIcons } from "../icons.js";
8
+ import { landmark, receipt, repeat, umbrella } from "../icon-library.js";
9
+ import { Badge, IconButton, TintIcon } from "./foundation.js";
10
+ import { Sparkline } from "./charts.js";
11
+ import { StatusIndicator } from "./status.js";
12
+ import type {
13
+ BudgetListProps,
14
+ ClaimTimelineProps,
15
+ CoverageMatrixProps,
16
+ DeadlineListProps,
17
+ DocumentListProps,
18
+ NetWorthCardProps,
19
+ PolicyCardProps,
20
+ SubscriptionListProps,
21
+ } from "./types.js";
22
+
23
+ // default icons of these components come from the icon library
24
+ registerIcons({ landmark, receipt, repeat, umbrella });
25
+
26
+ const DAY = 86400000;
27
+ /** Whole days from `from` to `to`, counted on the calendar (negative when `to` is earlier). */
28
+ function daysBetween(from: Date | string | number, to: Date | string | number): number {
29
+ const a = new Date(from),
30
+ b = new Date(to);
31
+ const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()),
32
+ ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
33
+ return Math.round((ub - ua) / DAY);
34
+ }
35
+
36
+ /* ── BudgetList ── */
37
+
38
+ /** Spending per category against its monthly limit, with what is left or over in words. */
39
+ export function BudgetList(p: BudgetListProps) {
40
+ const t = useStrings(HOUSEHOLD_STRINGS);
41
+ const { formatMoney } = useFormat();
42
+ const money = (n: number) => formatMoney(n, p.currency, { maximumFractionDigits: 0 });
43
+ return (
44
+ <ul style={p.style} className={cx("tf-budgets", p.className)} aria-label={p.label || t.budgets}>
45
+ {p.categories.map((c) => {
46
+ const pct = c.limit ? (c.spent / c.limit) * 100 : 0;
47
+ const over = c.spent > c.limit;
48
+ const tone = over ? "blush" : pct >= 85 ? "butter" : c.tint || "mint";
49
+ const body = (
50
+ <>
51
+ <TintIcon icon={c.icon || "wallet"} tint={c.tint || "sky"} size={32} />
52
+ <span className="tf-budget-main">
53
+ <span className="tf-budget-top">
54
+ <b>{c.label}</b>
55
+ <span className="tf-budget-nums">
56
+ {money(c.spent)} <small>/ {money(c.limit)}</small>
57
+ </span>
58
+ </span>
59
+ <span
60
+ className="tf-budget-bar"
61
+ role="progressbar"
62
+ aria-label={c.label}
63
+ aria-valuemin={0}
64
+ aria-valuemax={100}
65
+ aria-valuenow={Math.min(100, Math.round(pct))}
66
+ aria-valuetext={`${money(c.spent)} / ${money(c.limit)}`}
67
+ >
68
+ <span className={"tf-sf-" + tone} style={{ width: Math.min(100, pct) + "%" }} />
69
+ </span>
70
+ <small className={cx("tf-budget-left", over && "over")}>
71
+ {over
72
+ ? t.budgetOver(money(c.spent - c.limit))
73
+ : t.budgetLeft(money(c.limit - c.spent))}
74
+ </small>
75
+ </span>
76
+ </>
77
+ );
78
+ return (
79
+ <li key={c.id}>
80
+ {p.onSelect ? (
81
+ <button type="button" className="tf-budget" onClick={() => p.onSelect?.(c.id)}>
82
+ {body}
83
+ </button>
84
+ ) : (
85
+ <div className="tf-budget">{body}</div>
86
+ )}
87
+ </li>
88
+ );
89
+ })}
90
+ </ul>
91
+ );
92
+ }
93
+
94
+ /* ── SubscriptionList ── */
95
+
96
+ const PER_YEAR = { week: 52, month: 12, quarter: 4, year: 1 } as const;
97
+
98
+ /** Recurring payments with their next charge, and what they cost a month and a year together. */
99
+ export function SubscriptionList(p: SubscriptionListProps) {
100
+ const t = useStrings(HOUSEHOLD_STRINGS);
101
+ const { formatMoney, formatDate } = useFormat();
102
+ const money = (n: number, d = 2) =>
103
+ formatMoney(n, p.currency, { minimumFractionDigits: d, maximumFractionDigits: d });
104
+ const active = p.items.filter((s) => s.status !== "cancelled");
105
+ const year = active.reduce((sum, s) => sum + s.amount * PER_YEAR[s.period], 0);
106
+ return (
107
+ <div style={p.style} className={cx("tf-subs", p.className)}>
108
+ <ul aria-label={p.label || t.subscriptions}>
109
+ {p.items.map((s) => (
110
+ <li key={s.id} className={cx("tf-sub", s.status === "cancelled" && "is-cancelled")}>
111
+ {s.logo || <TintIcon icon={s.icon || "repeat"} tint="lilac" size={32} />}
112
+ <span className="tf-sub-text">
113
+ <b>{s.name}</b>
114
+ <small>
115
+ {[
116
+ s.category,
117
+ s.status === "cancelled"
118
+ ? t.cancelledLabel
119
+ : s.next != null &&
120
+ t.nextCharge(formatDate(s.next, { day: "numeric", month: "short" })),
121
+ ]
122
+ .filter(Boolean)
123
+ .join(" · ")}
124
+ </small>
125
+ </span>
126
+ {s.status === "trial" && <Badge tone="info">{t.trialEnds}</Badge>}
127
+ <span className="tf-sub-amount">
128
+ {money(s.amount)}
129
+ <small>/ {t.periodWord[s.period]}</small>
130
+ </span>
131
+ {p.onManage && (
132
+ <IconButton
133
+ icon="more"
134
+ label={t.manageItem(s.name)}
135
+ size="sm"
136
+ variant="ghost"
137
+ onClick={() => p.onManage?.(s.id)}
138
+ />
139
+ )}
140
+ </li>
141
+ ))}
142
+ </ul>
143
+ {p.totals !== false && (
144
+ <div className="tf-subs-total">
145
+ <b>{t.perMonthTotal(money(year / 12))}</b>
146
+ <span>{t.perYearTotal(money(year, 0))}</span>
147
+ </div>
148
+ )}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ /* ── NetWorthCard ── */
154
+
155
+ /** What you own minus what you owe, its trend, and the parts on both sides. */
156
+ export function NetWorthCard(p: NetWorthCardProps) {
157
+ const t = useStrings(HOUSEHOLD_STRINGS);
158
+ const { formatMoney } = useFormat();
159
+ const money = (n: number) => formatMoney(n, p.currency, { maximumFractionDigits: 0 });
160
+ const assets = p.assets.reduce((s, a) => s + a.value, 0);
161
+ const debts = (p.liabilities || []).reduce((s, a) => s + a.value, 0);
162
+ const side = (title: string, list: NetWorthCardProps["assets"], total: number) => (
163
+ <div className="tf-nw-side">
164
+ <div className="tf-nw-sidehead">
165
+ <span>{title}</span>
166
+ <b>{money(total)}</b>
167
+ </div>
168
+ <ul>
169
+ {list.map((a) => (
170
+ <li key={a.label}>
171
+ <span>
172
+ {a.icon && <Icon name={a.icon} size={14} />}
173
+ {a.label}
174
+ </span>
175
+ <span>{money(a.value)}</span>
176
+ </li>
177
+ ))}
178
+ </ul>
179
+ </div>
180
+ );
181
+ return (
182
+ <div style={p.style} className={cx("tf-nw", p.surface && "tf-" + p.surface, p.className)}>
183
+ <div className="tf-nw-head">
184
+ <div className="tf-col" style={{ gap: 2, minWidth: 0 }}>
185
+ <span className="tf-nw-title">{p.title || t.netWorth}</span>
186
+ <span className="tf-nw-total">{money(assets - debts)}</span>
187
+ {p.change != null && (
188
+ <span className={cx("tf-nw-change", p.change < 0 ? "down" : "up")}>
189
+ <Icon name="arrowUp" size={12} />
190
+ {(p.change > 0 ? "+" : "") + money(p.change)}
191
+ {p.changeLabel && <span> {p.changeLabel}</span>}
192
+ </span>
193
+ )}
194
+ </div>
195
+ {p.history && p.history.length > 1 && (
196
+ <Sparkline
197
+ values={p.history}
198
+ width={140}
199
+ height={48}
200
+ tint={(p.change ?? 0) < 0 ? "blush" : "mint"}
201
+ />
202
+ )}
203
+ </div>
204
+ <div className="tf-nw-sides">
205
+ {side(t.assetsLabel, p.assets, assets)}
206
+ {p.liabilities &&
207
+ p.liabilities.length > 0 &&
208
+ side(t.liabilitiesLabel, p.liabilities, debts)}
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ /* ── DeadlineList ── */
215
+
216
+ /** Bills, renewals, cancellation dates and expiring documents in date order, the urgent first. */
217
+ export function DeadlineList(p: DeadlineListProps) {
218
+ const t = useStrings(HOUSEHOLD_STRINGS);
219
+ const { formatMoney, formatDate } = useFormat();
220
+ const today = p.today ?? Date.now();
221
+ const soon = p.soon ?? 14;
222
+ const items = [...p.items].sort(
223
+ (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
224
+ );
225
+ return (
226
+ <ul
227
+ style={p.style}
228
+ className={cx("tf-deadlines", p.className)}
229
+ aria-label={p.label || t.deadlines}
230
+ >
231
+ {items.map((d) => {
232
+ const days = daysBetween(today, d.date);
233
+ const st = d.done ? "done" : days < 0 ? "overdue" : days <= soon ? "soon" : "later";
234
+ const when = days < 0 ? t.overdueBy(-days) : days === 0 ? t.dueToday : t.dueIn(days);
235
+ return (
236
+ <li key={d.id} className={cx("tf-deadline", "is-" + st)}>
237
+ <span className="tf-deadline-date">
238
+ <b>{formatDate(d.date, { day: "numeric" })}</b>
239
+ <small>{formatDate(d.date, { month: "short" })}</small>
240
+ </span>
241
+ <span className="tf-deadline-text">
242
+ <b>{d.title}</b>
243
+ <small>{[d.kind, d.note].filter(Boolean).join(" · ")}</small>
244
+ </span>
245
+ {d.amount != null && (
246
+ <span className="tf-deadline-amount">{formatMoney(d.amount, p.currency)}</span>
247
+ )}
248
+ {!d.done && (
249
+ <Badge tone={st === "overdue" ? "danger" : st === "soon" ? "warning" : "neutral"}>
250
+ {when}
251
+ </Badge>
252
+ )}
253
+ {p.onDone && !d.done && (
254
+ <IconButton
255
+ icon="check"
256
+ label={t.markDone(d.title)}
257
+ size="sm"
258
+ onClick={() => p.onDone?.(d.id)}
259
+ />
260
+ )}
261
+ {d.done && <Icon name="check" size={16} className="tf-deadline-done" />}
262
+ </li>
263
+ );
264
+ })}
265
+ </ul>
266
+ );
267
+ }
268
+
269
+ /* ── PolicyCard ── */
270
+
271
+ const POLICY_STATUS = {
272
+ active: "ok",
273
+ pending: "running",
274
+ expiring: "warn",
275
+ lapsed: "danger",
276
+ } as const;
277
+
278
+ /** One insurance policy: type, insurer, sum insured, premium, deductible, renewal and status. */
279
+ export function PolicyCard(p: PolicyCardProps) {
280
+ const t = useStrings(HOUSEHOLD_STRINGS);
281
+ const { formatMoney, formatDate } = useFormat();
282
+ const money = (n: number) => formatMoney(n, p.currency, { maximumFractionDigits: 0 });
283
+ const st = p.status || "active";
284
+ const no = p.policyNumber && p.maskNumber ? "•••• " + p.policyNumber.slice(-4) : p.policyNumber;
285
+ return (
286
+ <div
287
+ style={p.style}
288
+ className={cx("tf-policy", p.surface && "tf-" + p.surface, "is-" + st, p.className)}
289
+ >
290
+ <div className="tf-policy-head">
291
+ <TintIcon icon={p.icon || "umbrella"} tint={p.tint || "sky"} size={40} />
292
+ <div className="tf-option-text">
293
+ <b>{p.type}</b>
294
+ <small>{p.insurer}</small>
295
+ </div>
296
+ <StatusIndicator status={POLICY_STATUS[st]} label={t.policyStatus[st]} size="sm" />
297
+ </div>
298
+ <dl className="tf-policy-facts">
299
+ {p.coverage != null && (
300
+ <div>
301
+ <dt>{t.sumInsured}</dt>
302
+ <dd>{typeof p.coverage === "number" ? money(p.coverage) : p.coverage}</dd>
303
+ </div>
304
+ )}
305
+ {p.premium && (
306
+ <div>
307
+ <dt>{t.periodWord[p.premium.period]}</dt>
308
+ <dd>{formatMoney(p.premium.amount, p.currency)}</dd>
309
+ </div>
310
+ )}
311
+ {p.deductible != null && (
312
+ <div>
313
+ <dt>{t.deductibleLabel}</dt>
314
+ <dd>{money(p.deductible)}</dd>
315
+ </div>
316
+ )}
317
+ {no && (
318
+ <div>
319
+ <dt>{t.policyNo}</dt>
320
+ <dd className="tf-mono">{no}</dd>
321
+ </div>
322
+ )}
323
+ </dl>
324
+ {(p.renews != null || p.actions) && (
325
+ <div className="tf-policy-foot">
326
+ {p.renews != null && (
327
+ <span>
328
+ {t.renewsOn(
329
+ formatDate(p.renews, { day: "numeric", month: "short", year: "numeric" }),
330
+ )}
331
+ </span>
332
+ )}
333
+ {p.actions && <span className="tf-policy-actions">{p.actions}</span>}
334
+ </div>
335
+ )}
336
+ </div>
337
+ );
338
+ }
339
+
340
+ /* ── CoverageMatrix ── */
341
+
342
+ const COVER_STATUS = { covered: "ok", partial: "warn", none: "danger" } as const;
343
+
344
+ /** Life risks and whether a policy covers them: covered, partly, not covered, with notes. */
345
+ export function CoverageMatrix(p: CoverageMatrixProps) {
346
+ const t = useStrings(HOUSEHOLD_STRINGS);
347
+ const words = { covered: t.covered, partial: t.partlyCovered, none: t.notCovered };
348
+ const counts = { covered: 0, partial: 0, none: 0 };
349
+ p.rows.forEach((r) => counts[r.status]++);
350
+ return (
351
+ <div style={p.style} className={cx("tf-coverage", p.className)}>
352
+ <div className="tf-coverage-sum" aria-hidden>
353
+ {(["covered", "partial", "none"] as const).map((k) => (
354
+ <span key={k}>
355
+ <StatusIndicator
356
+ status={COVER_STATUS[k]}
357
+ label={`${counts[k]} ${words[k]}`}
358
+ size="sm"
359
+ />
360
+ </span>
361
+ ))}
362
+ </div>
363
+ <div className="tf-coverage-box">
364
+ <table className="tf-coverage-table">
365
+ <caption className="tf-sidebar-sr">{p.label || t.coverageLabel}</caption>
366
+ <tbody>
367
+ {p.rows.map((r) => (
368
+ <tr key={r.id} className={"is-" + r.status}>
369
+ <th scope="row">
370
+ <b>{r.risk}</b>
371
+ {r.policy && <small>{r.policy}</small>}
372
+ </th>
373
+ <td>
374
+ <StatusIndicator
375
+ status={COVER_STATUS[r.status]}
376
+ label={words[r.status]}
377
+ size="sm"
378
+ />
379
+ </td>
380
+ <td className="tf-coverage-note">{r.note}</td>
381
+ </tr>
382
+ ))}
383
+ </tbody>
384
+ </table>
385
+ </div>
386
+ </div>
387
+ );
388
+ }
389
+
390
+ /* ── ClaimTimeline ── */
391
+
392
+ /** An insurance claim from report to payout as steps with dates. */
393
+ export function ClaimTimeline(p: ClaimTimelineProps) {
394
+ const t = useStrings(HOUSEHOLD_STRINGS);
395
+ const { formatMoney, formatDate } = useFormat();
396
+ return (
397
+ <div style={p.style} className={cx("tf-claim", p.className)}>
398
+ <div className="tf-claim-head">
399
+ <div className="tf-option-text">
400
+ <b>{p.title}</b>
401
+ {p.meta && <small>{p.meta}</small>}
402
+ </div>
403
+ {p.amount != null && (
404
+ <span className="tf-claim-amount">
405
+ <small>{t.claimAmount}</small>
406
+ {formatMoney(p.amount, p.currency)}
407
+ </span>
408
+ )}
409
+ </div>
410
+ <ol className="tf-claim-steps">
411
+ {p.steps.map((s, i) => (
412
+ <li
413
+ key={i}
414
+ className={"is-" + s.state}
415
+ aria-current={s.state === "current" ? "step" : undefined}
416
+ >
417
+ <span className="tf-claim-dot" aria-hidden>
418
+ {s.state === "done" && <Icon name="check" size={10} />}
419
+ {s.state === "failed" && <Icon name="x" size={10} />}
420
+ </span>
421
+ <span className="tf-claim-text">
422
+ <b>{s.label}</b>
423
+ {(s.date != null || s.note) && (
424
+ <small>
425
+ {[
426
+ s.date != null && formatDate(s.date, { day: "numeric", month: "short" }),
427
+ s.note,
428
+ ]
429
+ .filter(Boolean)
430
+ .map((x, k) => (
431
+ <React.Fragment key={k}>
432
+ {k > 0 && " · "}
433
+ {x}
434
+ </React.Fragment>
435
+ ))}
436
+ </small>
437
+ )}
438
+ </span>
439
+ </li>
440
+ ))}
441
+ </ol>
442
+ </div>
443
+ );
444
+ }
445
+
446
+ /* ── DocumentList ── */
447
+
448
+ /** Important papers (IDs, contracts, policies, certificates) with holder and expiry. */
449
+ export function DocumentList(p: DocumentListProps) {
450
+ const t = useStrings(HOUSEHOLD_STRINGS);
451
+ const { formatDate } = useFormat();
452
+ const today = p.today ?? Date.now();
453
+ const soon = p.soon ?? 60;
454
+ return (
455
+ <ul style={p.style} className={cx("tf-docs", p.className)} aria-label={p.label || t.documents}>
456
+ {p.documents.map((d) => {
457
+ const days = d.expires != null ? daysBetween(today, d.expires) : null;
458
+ const st = days == null ? null : days < 0 ? "expired" : days <= soon ? "soon" : "ok";
459
+ const body = (
460
+ <>
461
+ <TintIcon icon={d.icon || "file"} tint={st === "expired" ? "blush" : "sky"} size={36} />
462
+ <span className="tf-doc-text">
463
+ <b>{d.title}</b>
464
+ <small>{[d.category, d.holder, d.meta].filter(Boolean).join(" · ")}</small>
465
+ </span>
466
+ {st === "expired" && <Badge tone="danger">{t.expired}</Badge>}
467
+ {st === "soon" && days != null && <Badge tone="warning">{t.expiresIn(days)}</Badge>}
468
+ {st === "ok" && d.expires != null && (
469
+ <span className="tf-doc-valid">
470
+ {t.expiresOn(formatDate(d.expires, { month: "short", year: "numeric" }))}
471
+ </span>
472
+ )}
473
+ </>
474
+ );
475
+ return (
476
+ <li key={d.id}>
477
+ {p.onOpen ? (
478
+ <button type="button" className="tf-doc" onClick={() => p.onOpen?.(d.id)}>
479
+ {body}
480
+ </button>
481
+ ) : (
482
+ <div className="tf-doc">{body}</div>
483
+ )}
484
+ </li>
485
+ );
486
+ })}
487
+ </ul>
488
+ );
489
+ }
@@ -21,6 +21,29 @@ export {
21
21
  TwoFactorSetup,
22
22
  ColorField,
23
23
  } from "./admin.js";
24
+ export { QrCode } from "./qr-code.js";
25
+ export {
26
+ HabitTracker,
27
+ MoodPicker,
28
+ VitalTile,
29
+ ActivityRings,
30
+ SleepChart,
31
+ MedicationList,
32
+ JournalEntry,
33
+ GoalCard,
34
+ InsightCard,
35
+ ConsentList,
36
+ } from "./life.js";
37
+ export {
38
+ BudgetList,
39
+ SubscriptionList,
40
+ NetWorthCard,
41
+ DeadlineList,
42
+ PolicyCard,
43
+ CoverageMatrix,
44
+ ClaimTimeline,
45
+ DocumentList,
46
+ } from "./household.js";
24
47
  export {
25
48
  AgentCard,
26
49
  AgentDetails,
@@ -157,7 +180,15 @@ export {
157
180
  AttachmentPreview,
158
181
  } from "./mail.js";
159
182
  export { ImageVariants, Gallery, BeforeAfter } from "./media.js";
160
- export { Tabs, Breadcrumb, Pagination, Menubar, NavigationMenu, Sidebar } from "./navigation.js";
183
+ export {
184
+ Tabs,
185
+ Breadcrumb,
186
+ Pagination,
187
+ Menubar,
188
+ NavigationMenu,
189
+ Sidebar,
190
+ UserMenu,
191
+ } from "./navigation.js";
161
192
  export {
162
193
  ConnectionCard,
163
194
  ConnectionBanner,
@@ -129,24 +129,60 @@ export function Textarea(p: TextareaProps) {
129
129
 
130
130
  export function SearchField(p: SearchFieldProps) {
131
131
  const t = useStrings();
132
+ const input = React.useRef<HTMLInputElement>(null);
133
+ // ⌘K / Ctrl+K: the last character of the hint is the key
134
+ const key = p.bindShortcut && p.shortcut ? p.shortcut.trim().slice(-1).toLowerCase() : "";
135
+ React.useEffect(() => {
136
+ if (!key) return;
137
+ const k = (e: KeyboardEvent) => {
138
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.key.toLowerCase() !== key) return;
139
+ const el = e.target as HTMLElement | null;
140
+ const typing =
141
+ !!el &&
142
+ el !== input.current &&
143
+ (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
144
+ if (typing) return;
145
+ e.preventDefault();
146
+ input.current?.focus();
147
+ input.current?.select();
148
+ };
149
+ document.addEventListener("keydown", k);
150
+ return () => document.removeEventListener("keydown", k);
151
+ }, [key]);
132
152
  return (
133
153
  <label className={cx("tf-search", p.className)} style={p.style}>
134
154
  <Icon name="search" size={15} />
135
155
  <input
156
+ ref={input}
136
157
  type="search"
158
+ id={p.id}
159
+ name={p.name}
160
+ autoFocus={p.autoFocus}
137
161
  placeholder={p.placeholder || t.search}
138
162
  aria-label={p.label || p.placeholder || t.search}
163
+ aria-keyshortcuts={
164
+ key ? `Meta+${key.toUpperCase()} Control+${key.toUpperCase()}` : undefined
165
+ }
139
166
  value={p.value}
167
+ defaultValue={p.defaultValue}
140
168
  onChange={p.onChange}
169
+ onKeyDown={p.onKeyDown}
170
+ onFocus={p.onFocus}
171
+ onBlur={p.onBlur}
172
+ {...p.inputProps}
141
173
  />
142
- {p.shortcut && <span className="tf-kbd">{p.shortcut}</span>}
174
+ {p.shortcut && (
175
+ <span className="tf-kbd" aria-hidden>
176
+ {p.shortcut}
177
+ </span>
178
+ )}
143
179
  </label>
144
180
  );
145
181
  }
146
182
 
147
183
  export function Checkbox(p: CheckboxProps) {
148
184
  return (
149
- <label className="tf-check">
185
+ <label style={p.style} className={cx("tf-check", p.className)}>
150
186
  <span className="tf-checkbox">
151
187
  <input
152
188
  type="checkbox"
@@ -182,15 +218,16 @@ export function Checkbox(p: CheckboxProps) {
182
218
  export function Switch(p: SwitchProps) {
183
219
  return (
184
220
  <label
185
- className="tf-check"
186
- style={
187
- p.spread
221
+ className={cx("tf-check", p.className)}
222
+ style={{
223
+ ...(p.spread
188
224
  ? {
189
225
  justifyContent: "space-between",
190
226
  width: "100%",
191
227
  }
192
- : undefined
193
- }
228
+ : undefined),
229
+ ...p.style,
230
+ }}
194
231
  >
195
232
  {p.spread && p.children}
196
233
  <input