@twentyfirstmedia/ui-core 0.2.0 → 0.3.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 (146) hide show
  1. package/dist/components/admin.d.ts.map +1 -1
  2. package/dist/components/admin.js +95 -71
  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 +49 -35
  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 +6 -2
  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 +22 -18
  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 +6 -2
  15. package/dist/components/building-blocks.js.map +1 -1
  16. package/dist/components/charts.d.ts.map +1 -1
  17. package/dist/components/charts.js +20 -10
  18. package/dist/components/charts.js.map +1 -1
  19. package/dist/components/chat.d.ts.map +1 -1
  20. package/dist/components/chat.js +31 -19
  21. package/dist/components/chat.js.map +1 -1
  22. package/dist/components/commerce.d.ts +2 -2
  23. package/dist/components/commerce.d.ts.map +1 -1
  24. package/dist/components/commerce.js +45 -31
  25. package/dist/components/commerce.js.map +1 -1
  26. package/dist/components/data-display.d.ts +5 -9
  27. package/dist/components/data-display.d.ts.map +1 -1
  28. package/dist/components/data-display.js +34 -35
  29. package/dist/components/data-display.js.map +1 -1
  30. package/dist/components/data-table.d.ts.map +1 -1
  31. package/dist/components/data-table.js +15 -10
  32. package/dist/components/data-table.js.map +1 -1
  33. package/dist/components/developer.d.ts.map +1 -1
  34. package/dist/components/developer.js +20 -11
  35. package/dist/components/developer.js.map +1 -1
  36. package/dist/components/feedback.d.ts.map +1 -1
  37. package/dist/components/feedback.js +12 -6
  38. package/dist/components/feedback.js.map +1 -1
  39. package/dist/components/form.d.ts +2 -1
  40. package/dist/components/form.d.ts.map +1 -1
  41. package/dist/components/form.js +12 -9
  42. package/dist/components/form.js.map +1 -1
  43. package/dist/components/forms.d.ts +2 -1
  44. package/dist/components/forms.d.ts.map +1 -1
  45. package/dist/components/forms.js +92 -42
  46. package/dist/components/forms.js.map +1 -1
  47. package/dist/components/foundation.d.ts +5 -2
  48. package/dist/components/foundation.d.ts.map +1 -1
  49. package/dist/components/foundation.js +15 -12
  50. package/dist/components/foundation.js.map +1 -1
  51. package/dist/components/inputs.d.ts.map +1 -1
  52. package/dist/components/inputs.js +3 -1
  53. package/dist/components/inputs.js.map +1 -1
  54. package/dist/components/mail.d.ts.map +1 -1
  55. package/dist/components/mail.js +15 -10
  56. package/dist/components/mail.js.map +1 -1
  57. package/dist/components/media.d.ts.map +1 -1
  58. package/dist/components/media.js +39 -18
  59. package/dist/components/media.js.map +1 -1
  60. package/dist/components/navigation.d.ts.map +1 -1
  61. package/dist/components/navigation.js +21 -9
  62. package/dist/components/navigation.js.map +1 -1
  63. package/dist/components/operations.d.ts.map +1 -1
  64. package/dist/components/operations.js +37 -27
  65. package/dist/components/operations.js.map +1 -1
  66. package/dist/components/previews.d.ts.map +1 -1
  67. package/dist/components/previews.js +14 -10
  68. package/dist/components/previews.js.map +1 -1
  69. package/dist/components/primitives.d.ts.map +1 -1
  70. package/dist/components/primitives.js +4 -1
  71. package/dist/components/primitives.js.map +1 -1
  72. package/dist/components/real-estate.d.ts.map +1 -1
  73. package/dist/components/real-estate.js +43 -28
  74. package/dist/components/real-estate.js.map +1 -1
  75. package/dist/components/rich-text-editor.d.ts.map +1 -1
  76. package/dist/components/rich-text-editor.js +18 -15
  77. package/dist/components/rich-text-editor.js.map +1 -1
  78. package/dist/components/select-menu.d.ts.map +1 -1
  79. package/dist/components/select-menu.js +4 -1
  80. package/dist/components/select-menu.js.map +1 -1
  81. package/dist/components/shell.d.ts.map +1 -1
  82. package/dist/components/shell.js +8 -3
  83. package/dist/components/shell.js.map +1 -1
  84. package/dist/components/site.d.ts.map +1 -1
  85. package/dist/components/site.js +17 -13
  86. package/dist/components/site.js.map +1 -1
  87. package/dist/components/types.d.ts +102 -3
  88. package/dist/components/types.d.ts.map +1 -1
  89. package/dist/components/workspace.d.ts.map +1 -1
  90. package/dist/components/workspace.js +50 -34
  91. package/dist/components/workspace.js.map +1 -1
  92. package/dist/context.d.ts +39 -0
  93. package/dist/context.d.ts.map +1 -0
  94. package/dist/context.js +76 -0
  95. package/dist/context.js.map +1 -0
  96. package/dist/format.d.ts +33 -6
  97. package/dist/format.d.ts.map +1 -1
  98. package/dist/format.js +75 -10
  99. package/dist/format.js.map +1 -1
  100. package/dist/index.d.ts +3 -2
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +3 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/provider.d.ts +18 -11
  105. package/dist/provider.d.ts.map +1 -1
  106. package/dist/provider.js +11 -18
  107. package/dist/provider.js.map +1 -1
  108. package/dist/strings.d.ts +503 -0
  109. package/dist/strings.d.ts.map +1 -0
  110. package/dist/strings.js +537 -0
  111. package/dist/strings.js.map +1 -0
  112. package/package.json +1 -1
  113. package/src/components/admin.tsx +117 -93
  114. package/src/components/agents.tsx +55 -41
  115. package/src/components/app-chrome.tsx +6 -2
  116. package/src/components/approvals.tsx +26 -22
  117. package/src/components/building-blocks.tsx +6 -2
  118. package/src/components/charts.tsx +21 -11
  119. package/src/components/chat.tsx +45 -35
  120. package/src/components/commerce.tsx +61 -53
  121. package/src/components/data-display.tsx +38 -45
  122. package/src/components/data-table.tsx +20 -15
  123. package/src/components/developer.tsx +26 -15
  124. package/src/components/feedback.tsx +19 -7
  125. package/src/components/form.tsx +17 -9
  126. package/src/components/forms.tsx +131 -53
  127. package/src/components/foundation.tsx +23 -36
  128. package/src/components/inputs.tsx +4 -2
  129. package/src/components/mail.tsx +16 -11
  130. package/src/components/media.tsx +64 -21
  131. package/src/components/navigation.tsx +25 -13
  132. package/src/components/operations.tsx +40 -32
  133. package/src/components/previews.tsx +17 -15
  134. package/src/components/primitives.tsx +8 -5
  135. package/src/components/real-estate.tsx +55 -35
  136. package/src/components/rich-text-editor.tsx +20 -17
  137. package/src/components/select-menu.tsx +5 -2
  138. package/src/components/shell.tsx +8 -3
  139. package/src/components/site.tsx +18 -14
  140. package/src/components/types.ts +111 -3
  141. package/src/components/workspace.tsx +79 -60
  142. package/src/context.ts +112 -0
  143. package/src/format.ts +108 -15
  144. package/src/index.ts +43 -0
  145. package/src/provider.tsx +36 -40
  146. package/src/strings.ts +1500 -0
@@ -2,7 +2,8 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { leaving, usePresence } from "../presence.js";
5
- import { formatMoney } from "../format.js";
5
+ import { useFormat, useStrings } from "../context.js";
6
+ import { WORKSPACE_STRINGS } from "../strings.js";
6
7
  import { Icon } from "../Icon.js";
7
8
  import { useScrollFocus, useTrap } from "../hooks.js";
8
9
  import { cx } from "../cx.js";
@@ -28,8 +29,9 @@ import type {
28
29
  WizardProps,
29
30
  } from "./types.js";
30
31
  export function SubNav(p: SubNavProps) {
32
+ const t = useStrings(WORKSPACE_STRINGS);
31
33
  return (
32
- <nav className="tf-subnav" aria-label={`${p.eyebrow || "Section"} pages`}>
34
+ <nav className="tf-subnav" aria-label={t.sectionPages(p.eyebrow)}>
33
35
  <div className="tf-subnav-head">
34
36
  {p.eyebrow && <span className="tf-subnav-eyebrow">{p.eyebrow}</span>}
35
37
  {p.action && (
@@ -65,6 +67,7 @@ export function SubNav(p: SubNavProps) {
65
67
  );
66
68
  }
67
69
  export function Drawer(p: DrawerProps) {
70
+ const t = useStrings(WORKSPACE_STRINGS);
68
71
  const ref = React.useRef<HTMLElement>(null);
69
72
  const pr = usePresence(p.open ?? true);
70
73
  useTrap(ref, p.onClose);
@@ -114,7 +117,7 @@ export function Drawer(p: DrawerProps) {
114
117
  {p.subtitle && <div className="tf-panel-sub">{p.subtitle}</div>}
115
118
  </div>
116
119
  </div>
117
- <IconButton icon="x" label="Close" onClick={p.onClose} />
120
+ <IconButton icon="x" label={t.close} onClick={p.onClose} />
118
121
  </div>
119
122
  {p.children}
120
123
  </aside>
@@ -122,6 +125,7 @@ export function Drawer(p: DrawerProps) {
122
125
  );
123
126
  }
124
127
  export function CommandPalette(p: CommandPaletteProps) {
128
+ const t = useStrings(WORKSPACE_STRINGS);
125
129
  const q = React.useState(""),
126
130
  idx = React.useState(0),
127
131
  ref = React.useRef<HTMLDivElement>(null),
@@ -165,7 +169,7 @@ export function CommandPalette(p: CommandPaletteProps) {
165
169
  items: [PaletteItem, number][];
166
170
  }[] = [];
167
171
  res.forEach((it, i) => {
168
- const g = it.group || "Results";
172
+ const g = it.group || t.results;
169
173
  if (!groups.length || groups[groups.length - 1].g !== g)
170
174
  groups.push({
171
175
  g: g,
@@ -187,13 +191,13 @@ export function CommandPalette(p: CommandPaletteProps) {
187
191
  className="tf-palette"
188
192
  role="dialog"
189
193
  aria-modal="true"
190
- aria-label="Command palette"
194
+ aria-label={t.commandPalette}
191
195
  >
192
196
  <div className="tf-palette-input">
193
197
  <Icon name="search" size={18} />
194
198
  <input
195
- placeholder={p.placeholder || "Search pages, actions and records…"}
196
- aria-label="Search"
199
+ placeholder={p.placeholder || t.paletteSearchPlaceholder}
200
+ aria-label={t.search}
197
201
  value={q[0]}
198
202
  onChange={(e) => {
199
203
  q[1](e.target.value);
@@ -202,12 +206,12 @@ export function CommandPalette(p: CommandPaletteProps) {
202
206
  }}
203
207
  onKeyDown={key}
204
208
  />
205
- <kbd>{"Esc"}</kbd>
209
+ <kbd>{t.escKey}</kbd>
206
210
  </div>
207
- <div className="tf-palette-list" role="listbox" aria-label="Results">
211
+ <div className="tf-palette-list" role="listbox" aria-label={t.results}>
208
212
  {p.loading && (
209
213
  <div className="tf-palette-empty" role="status">
210
- <Spinner /> Searching…
214
+ <Spinner /> {t.searching}
211
215
  </div>
212
216
  )}
213
217
  {res.length ? (
@@ -243,21 +247,21 @@ export function CommandPalette(p: CommandPaletteProps) {
243
247
  </div>
244
248
  ))
245
249
  ) : p.loading ? null : (
246
- <div className="tf-palette-empty">{p.empty ?? `Nothing matches “${q[0]}”.`}</div>
250
+ <div className="tf-palette-empty">{p.empty ?? t.nothingMatches(q[0])}</div>
247
251
  )}
248
252
  </div>
249
253
  <div className="tf-palette-foot">
250
254
  <span>
251
255
  <kbd>↑</kbd>
252
- <kbd>↓</kbd> {"move"}
256
+ <kbd>↓</kbd> {t.paletteMove}
253
257
  </span>
254
258
  <span>
255
- <kbd>↵</kbd> {"open"}
259
+ <kbd>↵</kbd> {t.paletteOpen}
256
260
  </span>
257
261
  <span>
258
262
  <kbd>⌘</kbd>
259
263
  <kbd>K</kbd>
260
- {" anywhere"}
264
+ {" " + t.paletteAnywhere}
261
265
  </span>
262
266
  </div>
263
267
  </div>
@@ -265,10 +269,11 @@ export function CommandPalette(p: CommandPaletteProps) {
265
269
  );
266
270
  }
267
271
  export function NotificationList(p: NotificationListProps) {
268
- const f = React.useState("All"),
269
- items = (p.items || []).filter((n) => f[0] === "All" || n.unread);
272
+ const t = useStrings(WORKSPACE_STRINGS);
273
+ const f = React.useState<"all" | "unread">("all"),
274
+ items = (p.items || []).filter((n) => f[0] === "all" || n.unread);
270
275
  return (
271
- <div className="tf-notes" role="region" aria-label="Notifications">
276
+ <div className="tf-notes" role="region" aria-label={t.notifications}>
272
277
  <div
273
278
  className="tf-header"
274
279
  style={{
@@ -281,7 +286,7 @@ export function NotificationList(p: NotificationListProps) {
281
286
  fontSize: 16,
282
287
  }}
283
288
  >
284
- {"Notifications"}
289
+ {t.notifications}
285
290
  </b>
286
291
  <Button
287
292
  variant="ghost"
@@ -290,7 +295,7 @@ export function NotificationList(p: NotificationListProps) {
290
295
  disabled={!(p.items || []).some((n) => n.unread)}
291
296
  onClick={p.onReadAll}
292
297
  >
293
- {"Mark all read"}
298
+ {t.markAllRead}
294
299
  </Button>
295
300
  </div>
296
301
  <div
@@ -298,7 +303,12 @@ export function NotificationList(p: NotificationListProps) {
298
303
  padding: "0 8px",
299
304
  }}
300
305
  >
301
- <Tabs block items={["All", "Unread"]} value={f[0]} onChange={f[1]} />
306
+ <Tabs
307
+ block
308
+ items={[t.all, t.unread]}
309
+ value={f[0] === "all" ? t.all : t.unread}
310
+ onChange={(v) => f[1](v === t.unread ? "unread" : "all")}
311
+ />
302
312
  </div>
303
313
  <div className="tf-notes-list">
304
314
  {items.length ? (
@@ -325,21 +335,22 @@ export function NotificationList(p: NotificationListProps) {
325
335
  ) : (
326
336
  <div className="tf-notes-empty">
327
337
  <TintIcon icon="check" tint="mint" size={40} />
328
- <b>{"You are all caught up"}</b>
338
+ <b>{t.allCaughtUp}</b>
329
339
  </div>
330
340
  )}
331
341
  </div>
332
342
  </div>
333
343
  );
334
344
  }
335
- const STEP = (): Record<FlowStep["kind"], [IconName, Tint, string]> => ({
336
- trigger: ["zap", "sky", "When"],
337
- condition: ["filter", "blush", "If"],
338
- agent: ["bot", "lilac", "Agent"],
339
- approval: ["user", "butter", "Approval"],
340
- action: ["send", "mint", "Then"],
345
+ const STEP = (): Record<FlowStep["kind"], [IconName, Tint]> => ({
346
+ trigger: ["zap", "sky"],
347
+ condition: ["filter", "blush"],
348
+ agent: ["bot", "lilac"],
349
+ approval: ["user", "butter"],
350
+ action: ["send", "mint"],
341
351
  });
342
352
  export function Flow(p: FlowProps) {
353
+ const t = useStrings(WORKSPACE_STRINGS);
343
354
  return (
344
355
  <ol
345
356
  className="tf-flow tf-stagger"
@@ -351,7 +362,8 @@ export function Flow(p: FlowProps) {
351
362
  >
352
363
  {(p.steps || []).map((s, i) => {
353
364
  const steps = STEP(),
354
- k = steps[s.kind] || steps.action,
365
+ kind = steps[s.kind] ? s.kind : "action",
366
+ k = steps[kind],
355
367
  st =
356
368
  i < (p.active == null ? -1 : p.active)
357
369
  ? "done"
@@ -367,9 +379,9 @@ export function Flow(p: FlowProps) {
367
379
  </span>
368
380
  <div className="tf-flow-card">
369
381
  <div className="tf-flow-kind">
370
- {k[2]}
371
- {st === "active" && <span className="tf-flow-live">running</span>}
372
- {st === "wait" && <span className="tf-flow-live wait">{"waiting for you"}</span>}
382
+ {t.flowStep[kind]}
383
+ {st === "active" && <span className="tf-flow-live">{t.flowRunning}</span>}
384
+ {st === "wait" && <span className="tf-flow-live wait">{t.waitingForYou}</span>}
373
385
  </div>
374
386
  <div className="tf-flow-text">{s.text}</div>
375
387
  {st === "wait" && p.onApprove && (
@@ -380,11 +392,11 @@ export function Flow(p: FlowProps) {
380
392
  }}
381
393
  >
382
394
  <Button size="sm" icon="check" onClick={p.onApprove}>
383
- {"Approve"}
395
+ {t.approve}
384
396
  </Button>
385
397
  {p.onReject && (
386
398
  <Button size="sm" variant="ghost" onClick={p.onReject}>
387
- {"Reject"}
399
+ {t.reject}
388
400
  </Button>
389
401
  )}
390
402
  </div>
@@ -474,6 +486,7 @@ export function FileThumb(p: FileThumbProps) {
474
486
  );
475
487
  }
476
488
  export function FileCard(p: FileCardProps) {
489
+ const t = useStrings(WORKSPACE_STRINGS);
477
490
  const k = FILE_KIND[p.kind] || FILE_KIND.doc;
478
491
  return (
479
492
  <button type="button" className="tf-filecard" onClick={p.onClick}>
@@ -483,17 +496,18 @@ export function FileCard(p: FileCardProps) {
483
496
  <span className="tf-filecard-name">{p.name}</span>
484
497
  <span className="tf-filecard-meta">
485
498
  {[p.size, p.meta].filter(Boolean).join(" · ")}
486
- {p.shared && <Icon name="users" size={12} label="Shared" />}
499
+ {p.shared && <Icon name="users" size={12} label={t.shared} />}
487
500
  </span>
488
501
  </button>
489
502
  );
490
503
  }
491
504
  export function Wizard(p: WizardProps) {
505
+ const t = useStrings(WORKSPACE_STRINGS);
492
506
  const steps = p.steps || [],
493
507
  cur = p.current || 0,
494
508
  done = p.done || {};
495
509
  return (
496
- <div className="tf-wizard" role="group" aria-label={p.label || "Setup"}>
510
+ <div className="tf-wizard" role="group" aria-label={p.label || t.setup}>
497
511
  <aside className="tf-wizard-rail">
498
512
  {p.brand && (
499
513
  <span
@@ -528,7 +542,7 @@ export function Wizard(p: WizardProps) {
528
542
  </ol>
529
543
  </aside>
530
544
  <div className="tf-wizard-main">
531
- <div className="tf-card-desc">{`Step ${cur + 1} of ${steps.length}`}</div>
545
+ <div className="tf-card-desc">{t.stepOf(cur + 1, steps.length)}</div>
532
546
  <div key={cur} className="tf-wizard-body">
533
547
  {steps[cur] && steps[cur].icon && (
534
548
  <TintIcon icon={steps[cur].icon} tint="sky" size={44} />
@@ -550,8 +564,9 @@ export function Wizard(p: WizardProps) {
550
564
  </div>
551
565
  );
552
566
  }
553
- const LEVELS = () => ["None", "View", "Edit", "Full"];
554
567
  export function PermissionMatrix(p: PermissionMatrixProps) {
568
+ const t = useStrings(WORKSPACE_STRINGS);
569
+ const LEVELS = () => t.permissionLevels;
555
570
  const box = React.useRef<HTMLDivElement>(null);
556
571
  useScrollFocus(box);
557
572
  return (
@@ -559,7 +574,7 @@ export function PermissionMatrix(p: PermissionMatrixProps) {
559
574
  <table className="tf-table tf-perm">
560
575
  <thead>
561
576
  <tr>
562
- <th>{"Role"}</th>
577
+ <th>{t.role}</th>
563
578
  {(p.areas || []).map((a) => (
564
579
  <th key={a}>{a}</th>
565
580
  ))}
@@ -583,11 +598,7 @@ export function PermissionMatrix(p: PermissionMatrixProps) {
583
598
  >
584
599
  {r.name}
585
600
  </div>
586
- {r.people != null && (
587
- <div className="tf-card-desc">
588
- {r.people === 1 ? "1 member" : `${r.people} members`}
589
- </div>
590
- )}
601
+ {r.people != null && <div className="tf-card-desc">{t.memberCount(r.people)}</div>}
591
602
  </td>
592
603
  {r.levels.map((lv, ai) => (
593
604
  <td key={ai}>
@@ -611,12 +622,18 @@ export function PermissionMatrix(p: PermissionMatrixProps) {
611
622
  </div>
612
623
  );
613
624
  }
614
- function money2(n: number) {
615
- return formatMoney(n, null, {
616
- minimumFractionDigits: 2,
617
- });
625
+ /** Money with cents, in the locale of the nearest UIProvider. */
626
+ function useMoney2() {
627
+ const { formatMoney } = useFormat();
628
+ return (n: number) =>
629
+ formatMoney(n, null, {
630
+ minimumFractionDigits: 2,
631
+ });
618
632
  }
619
633
  export function InvoicePreview(p: InvoicePreviewProps) {
634
+ const t = useStrings(WORKSPACE_STRINGS);
635
+ const money2 = useMoney2();
636
+ const { formatPercent } = useFormat();
620
637
  const lines = p.lines || [];
621
638
  let net = 0;
622
639
  const vat: Record<number, number> = {};
@@ -649,47 +666,47 @@ export function InvoicePreview(p: InvoicePreviewProps) {
649
666
  {p.from && <div className="tf-paper-small">{p.from}</div>}
650
667
  </div>
651
668
  <div className="tf-paper-id">
652
- <div className="tf-paper-kind">{p.kind || "Invoice"}</div>
669
+ <div className="tf-paper-kind">{p.kind || t.invoice}</div>
653
670
  <div className="tf-mono">{"#" + p.number}</div>
654
671
  </div>
655
672
  </div>
656
673
  <div className="tf-paper-meta">
657
674
  <div>
658
- <span>{"Bill to"}</span>
675
+ <span>{t.billTo}</span>
659
676
  <b>{p.to}</b>
660
677
  </div>
661
678
  <div>
662
- <span>{"Issued"}</span>
679
+ <span>{t.issued}</span>
663
680
  <b>{p.issued}</b>
664
681
  </div>
665
682
  <div>
666
- <span>{"Due"}</span>
683
+ <span>{t.due}</span>
667
684
  <b>{p.due}</b>
668
685
  </div>
669
686
  </div>
670
687
  <div className="tf-paper-lines">
671
688
  {lines.map((l, i) => (
672
689
  <div key={i}>
673
- <span>{(l.d || "Untitled line") + (l.q !== 1 ? " × " + l.q : "")}</span>
690
+ <span>{(l.d || t.untitledLine) + (l.q !== 1 ? " × " + l.q : "")}</span>
674
691
  <b>{fmt(l.q * l.u)}</b>
675
692
  </div>
676
693
  ))}
677
694
  </div>
678
695
  <div className="tf-paper-totals">
679
696
  <div>
680
- <span>{"Net"}</span>
697
+ <span>{t.net}</span>
681
698
  <b>{fmt(net)}</b>
682
699
  </div>
683
700
  {Object.keys(vat)
684
701
  .filter((k) => +k > 0)
685
702
  .map((k) => (
686
703
  <div key={k}>
687
- <span>{`VAT ${k}%`}</span>
704
+ <span>{t.vatAt(formatPercent(Number(k), { maximumFractionDigits: 2 }))}</span>
688
705
  <b>{fmt(vat[Number(k)] ?? 0)}</b>
689
706
  </div>
690
707
  ))}
691
708
  <div className="grand">
692
- <span>{"Total due"}</span>
709
+ <span>{t.totalDue}</span>
693
710
  <b>{fmt(net + tax)}</b>
694
711
  </div>
695
712
  </div>
@@ -698,6 +715,8 @@ export function InvoicePreview(p: InvoicePreviewProps) {
698
715
  );
699
716
  }
700
717
  export function TransactionRow(p: TransactionRowProps) {
718
+ const t = useStrings(WORKSPACE_STRINGS);
719
+ const money2 = useMoney2();
701
720
  const open = React.useState(false),
702
721
  inc = p.amount > 0;
703
722
  return (
@@ -706,13 +725,13 @@ export function TransactionRow(p: TransactionRowProps) {
706
725
  <TintIcon icon={inc ? "download" : "send"} tint={inc ? "mint" : "plain"} />
707
726
  <div className="tf-tx-text">
708
727
  <b className="tf-mono">{p.text}</b>
709
- <span>{p.date + (p.matched && p.matchRef ? ` · matched to ${p.matchRef}` : "")}</span>
728
+ <span>{p.date + (p.matched && p.matchRef ? " · " + t.matchedTo(p.matchRef) : "")}</span>
710
729
  </div>
711
730
  <b className={cx("tf-tx-amt", inc ? "in" : "out")}>
712
731
  {(inc ? "+" : "−") + money2(Math.abs(p.amount))}
713
732
  </b>
714
733
  {p.matched ? (
715
- <Badge tone="success">{"Matched"}</Badge>
734
+ <Badge tone="success">{t.matched}</Badge>
716
735
  ) : (
717
736
  <Button
718
737
  size="sm"
@@ -721,7 +740,7 @@ export function TransactionRow(p: TransactionRowProps) {
721
740
  open[1](!open[0]);
722
741
  }}
723
742
  >
724
- {open[0] ? "Close" : "Match"}
743
+ {open[0] ? t.close : t.match}
725
744
  </Button>
726
745
  )}
727
746
  </div>
@@ -734,7 +753,7 @@ export function TransactionRow(p: TransactionRowProps) {
734
753
  minWidth: 0,
735
754
  }}
736
755
  >
737
- {p.hint || "No suggestion yet."}
756
+ {p.hint || t.noSuggestion}
738
757
  </span>
739
758
  <Button
740
759
  size="sm"
@@ -744,7 +763,7 @@ export function TransactionRow(p: TransactionRowProps) {
744
763
  p.onMatch && p.onMatch();
745
764
  }}
746
765
  >
747
- {"Accept"}
766
+ {t.accept}
748
767
  </Button>
749
768
  </div>
750
769
  )}
package/src/context.ts ADDED
@@ -0,0 +1,112 @@
1
+ "use client";
2
+ /**
3
+ * The context `UIProvider` fills, and the hooks components read it with. Kept apart from the
4
+ * provider so a component that only needs its strings or formatters does not pull in theming.
5
+ */
6
+ import { createContext, useContext } from "react";
7
+ import { LOCALE, formatDate, formatMoney, formatNumber, formatPercent } from "./format.js";
8
+ import {
9
+ DEFAULT_STRINGS,
10
+ SHARED_STRINGS,
11
+ mergeStrings,
12
+ type UIMessages,
13
+ type UIStrings,
14
+ } from "./strings.js";
15
+ import type { Density, Kit, Motion, Surface, Theme } from "./provider.js";
16
+
17
+ export interface UIContextValue {
18
+ kit: Kit | undefined;
19
+ theme: Theme | undefined;
20
+ density: Density | undefined;
21
+ surface: Surface | undefined;
22
+ motion: Motion | undefined;
23
+ /** Locale for formatted numbers and dates (`"en-US"` unless a provider sets one). */
24
+ locale: string;
25
+ /** Every provider's `messages`, merged (outer first). Components add their defaults. */
26
+ messages: UIMessages;
27
+ /** Element portals render into (null: document.body). */
28
+ portalRoot: HTMLElement | null;
29
+ }
30
+
31
+ export const UIContext = createContext<UIContextValue>({
32
+ kit: undefined,
33
+ theme: undefined,
34
+ density: undefined,
35
+ surface: undefined,
36
+ motion: undefined,
37
+ locale: LOCALE,
38
+ messages: {},
39
+ portalRoot: null,
40
+ });
41
+
42
+ /** Current kit, theme, density, locale, strings and portal target. */
43
+ export function useUI(): UIContextValue {
44
+ return useContext(UIContext);
45
+ }
46
+
47
+ // Resolved strings per (messages, defaults): components get stable objects, and an app without
48
+ // messages shares one object per group.
49
+ const resolved = new WeakMap<object, WeakMap<object, unknown>>();
50
+ function resolve<T extends object>(messages: UIMessages, defaults: T): T {
51
+ let byGroup = resolved.get(messages);
52
+ if (!byGroup) resolved.set(messages, (byGroup = new WeakMap()));
53
+ let out = byGroup.get(defaults) as T | undefined;
54
+ if (!out) {
55
+ out = mergeStrings(defaults as unknown as UIStrings, messages) as unknown as T;
56
+ byGroup.set(defaults, out);
57
+ }
58
+ return out;
59
+ }
60
+ const NO_GROUP = {};
61
+
62
+ /**
63
+ * Strings for a component: the shared strings plus `group` (the component file's defaults), with
64
+ * the `messages` of the surrounding providers applied. Each component file passes only its own
65
+ * group, so a bundle carries just the defaults of the components it uses.
66
+ */
67
+ export function useStrings<G extends object = Record<never, never>>(
68
+ group?: G,
69
+ ): typeof SHARED_STRINGS & G {
70
+ const messages = useContext(UIContext).messages;
71
+ const g = group ?? (NO_GROUP as G);
72
+ let base = withShared.get(g) as (typeof SHARED_STRINGS & G) | undefined;
73
+ if (!base) withShared.set(g, (base = { ...SHARED_STRINGS, ...g }));
74
+ return resolve(messages, base);
75
+ }
76
+ const withShared = new WeakMap<object, object>();
77
+
78
+ /** Every built-in string for this part of the tree (English unless a `UIProvider` sets `messages`). */
79
+ export function useUIStrings(): UIStrings {
80
+ return resolve(useContext(UIContext).messages, DEFAULT_STRINGS);
81
+ }
82
+
83
+ /** `formatNumber`, `formatMoney`, `formatPercent` and `formatDate`, bound to a locale. */
84
+ export interface Formatters {
85
+ locale: string;
86
+ formatNumber: (v: number, o?: Intl.NumberFormatOptions) => string;
87
+ formatMoney: (v: number, currency?: string | null, o?: Intl.NumberFormatOptions) => string;
88
+ formatPercent: (v: number, o?: Intl.NumberFormatOptions) => string;
89
+ formatDate: (input: Date | string | number, o?: Intl.DateTimeFormatOptions) => string;
90
+ }
91
+
92
+ const formatters = new Map<string, Formatters>();
93
+ /** The formatters for a locale (cached, so the functions keep their identity). */
94
+ export function formattersFor(locale: string): Formatters {
95
+ let f = formatters.get(locale);
96
+ if (!f) {
97
+ f = {
98
+ locale,
99
+ formatNumber: (v, o) => formatNumber(v, o, locale),
100
+ formatMoney: (v, currency, o) => formatMoney(v, currency, o, locale),
101
+ formatPercent: (v, o) => formatPercent(v, o, locale),
102
+ formatDate: (input, o) => formatDate(input, o, locale),
103
+ };
104
+ formatters.set(locale, f);
105
+ }
106
+ return f;
107
+ }
108
+
109
+ /** The format helpers in the locale of the nearest `UIProvider` (`"en-US"` without one). */
110
+ export function useFormat(): Formatters {
111
+ return formattersFor(useContext(UIContext).locale);
112
+ }