@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,6 +2,8 @@
2
2
  import * as React from "react";
3
3
  import { cx } from "../cx.js";
4
4
  import { formatMoney } from "../format.js";
5
+ import { useFormat, useStrings } from "../context.js";
6
+ import { COMMERCE_STRINGS } from "../strings.js";
5
7
  import { Icon } from "../Icon.js";
6
8
  import { useControlled } from "../hooks.js";
7
9
  import { Badge, Button, IconButton } from "./foundation.js";
@@ -65,12 +67,19 @@ export function ProductArt(p: ProductArtProps) {
65
67
 
66
68
  ProductArt.kinds = Object.keys(ART) as ArtKind[];
67
69
 
68
- /** Formats an amount with the configured locale and currency. */
69
- export function money(v: number | string, cur?: string) {
70
- return formatMoney(Number(v), cur);
70
+ /** Formats an amount in a currency (default euros) and locale (default en-US). */
71
+ export function money(v: number | string, cur?: string, locale?: string) {
72
+ return formatMoney(Number(v), cur, undefined, locale);
73
+ }
74
+ /** `money` in the locale of the nearest `UIProvider`. */
75
+ function useMoney() {
76
+ const { locale } = useFormat();
77
+ return (v: number | string, cur?: string) => money(v, cur, locale);
71
78
  }
72
79
 
73
80
  export function Price(p: PriceProps) {
81
+ const money = useMoney();
82
+ const { formatPercent } = useFormat();
74
83
  const sale = p.compareAt && p.compareAt > p.value;
75
84
  return (
76
85
  <span className={cx("tf-price", p.size && "tf-price-" + p.size)}>
@@ -78,7 +87,7 @@ export function Price(p: PriceProps) {
78
87
  {sale && <s>{money(p.compareAt ?? 0, p.currency)}</s>}
79
88
  {sale && p.showSave !== false && (
80
89
  <span className="tf-price-save">
81
- {"−" + Math.round((1 - p.value / (p.compareAt || p.value)) * 100) + "%"}
90
+ {"−" + formatPercent(Math.round((1 - p.value / (p.compareAt || p.value)) * 100))}
82
91
  </span>
83
92
  )}
84
93
  </span>
@@ -86,13 +95,12 @@ export function Price(p: PriceProps) {
86
95
  }
87
96
 
88
97
  export function Rating(p: RatingProps) {
89
- const v = p.value || 0;
98
+ const t = useStrings(COMMERCE_STRINGS);
99
+ const { formatNumber } = useFormat();
100
+ const v = p.value || 0,
101
+ shown = formatNumber(v, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
90
102
  return (
91
- <span
92
- className="tf-rating"
93
- role="img"
94
- aria-label={`${v.toFixed(1)} out of 5` + (p.count != null ? `, ${p.count} reviews` : "")}
95
- >
103
+ <span className="tf-rating" role="img" aria-label={t.ratingLabel(shown, p.count)}>
96
104
  <span className="tf-stars" aria-hidden>
97
105
  {[0, 1, 2, 3, 4].map((i) => {
98
106
  const f = Math.max(0, Math.min(1, v - i));
@@ -111,25 +119,27 @@ export function Rating(p: RatingProps) {
111
119
  );
112
120
  })}
113
121
  </span>
114
- <b>{v.toFixed(1)}</b>
115
- {p.count != null && <span>{"(" + p.count + ")"}</span>}
122
+ <b>{shown}</b>
123
+ {p.count != null && <span>{"(" + formatNumber(p.count) + ")"}</span>}
116
124
  </span>
117
125
  );
118
126
  }
119
127
 
120
128
  export function StockBadge(p: StockBadgeProps) {
129
+ const t = useStrings(COMMERCE_STRINGS);
121
130
  const s = p.stock;
122
131
  if (s == null) return null;
123
132
  return s <= 0 ? (
124
- <Badge tone="danger">{"Out of stock"}</Badge>
133
+ <Badge tone="danger">{t.outOfStock}</Badge>
125
134
  ) : s <= (p.low || 10) ? (
126
- <Badge tone="warning">{`Low stock · ${s} left`}</Badge>
135
+ <Badge tone="warning">{t.lowStock(s)}</Badge>
127
136
  ) : (
128
- <Badge tone="success">{p.compact ? "In stock" : `In stock · ${s}`}</Badge>
137
+ <Badge tone="success">{p.compact ? t.inStock : t.inStockCount(s)}</Badge>
129
138
  );
130
139
  }
131
140
 
132
141
  export function ProductCard(p: ProductCardProps) {
142
+ const t = useStrings(COMMERCE_STRINGS);
133
143
  return (
134
144
  <div className={cx("tf-product", p.className)} onClick={p.onClick}>
135
145
  <div className="tf-product-media">
@@ -166,11 +176,11 @@ export function ProductCard(p: ProductCardProps) {
166
176
  <Price value={p.price} compareAt={p.compareAt} />
167
177
  {p.onAdd &&
168
178
  (p.stock === 0 ? (
169
- <span className="tf-card-desc">{"Sold out"}</span>
179
+ <span className="tf-card-desc">{t.soldOut}</span>
170
180
  ) : (
171
181
  <IconButton
172
182
  icon={p.added ? "check" : "plus"}
173
- label={`Add ${p.name} to cart`}
183
+ label={t.addToCart(p.name)}
174
184
  variant={p.added ? "solid" : "soft"}
175
185
  size="sm"
176
186
  onClick={(e) => {
@@ -186,6 +196,7 @@ export function ProductCard(p: ProductCardProps) {
186
196
  }
187
197
 
188
198
  export function VariantPicker(p: VariantPickerProps) {
199
+ const t = useStrings(COMMERCE_STRINGS);
189
200
  const opts = (p.options || []).map((o) => (typeof o === "string" ? { value: o, label: o } : o));
190
201
  const v = useControlled(p.value, p.defaultValue ?? "", p.onChange),
191
202
  color = p.type === "color";
@@ -209,7 +220,7 @@ export function VariantPicker(p: VariantPickerProps) {
209
220
  className={cx("tf-variant", on && "on", off && "off")}
210
221
  aria-pressed={on}
211
222
  disabled={off}
212
- aria-label={color ? (off ? `${lab} (sold out)` : lab) : undefined}
223
+ aria-label={color ? (off ? t.variantSoldOut(lab) : lab) : undefined}
213
224
  title={color ? lab : undefined}
214
225
  onClick={() => {
215
226
  v[1](val);
@@ -234,6 +245,7 @@ export function VariantPicker(p: VariantPickerProps) {
234
245
  }
235
246
 
236
247
  export function CartLine(p: CartLineProps) {
248
+ const t = useStrings(COMMERCE_STRINGS);
237
249
  return (
238
250
  <div className="tf-cartline">
239
251
  <ProductArt kind={p.art} tint={p.tint} size={64} />
@@ -246,7 +258,7 @@ export function CartLine(p: CartLineProps) {
246
258
  <div className="tf-number tf-number-sm">
247
259
  <IconButton
248
260
  icon={p.qty <= 1 ? "trash" : "minus"}
249
- label={p.qty <= 1 ? `Remove ${p.name}` : "One less"}
261
+ label={p.qty <= 1 ? t.removeItem(p.name) : t.oneLess}
250
262
  size="sm"
251
263
  onClick={() => {
252
264
  p.qty <= 1 ? p.onRemove && p.onRemove() : p.onQty?.(p.qty - 1);
@@ -257,7 +269,7 @@ export function CartLine(p: CartLineProps) {
257
269
  </span>
258
270
  <IconButton
259
271
  icon="plus"
260
- label="One more"
272
+ label={t.oneMore}
261
273
  size="sm"
262
274
  onClick={() => {
263
275
  p.onQty?.(p.qty + 1);
@@ -273,6 +285,9 @@ export function CartLine(p: CartLineProps) {
273
285
  }
274
286
 
275
287
  export function OrderSummary(p: OrderSummaryProps) {
288
+ const t = useStrings(COMMERCE_STRINGS);
289
+ const money = useMoney();
290
+ const { formatPercent } = useFormat();
276
291
  const lines = p.lines || [],
277
292
  sub = lines.reduce((a, l) => a + l.price * l.qty, 0),
278
293
  disc = p.discount
@@ -288,7 +303,7 @@ export function OrderSummary(p: OrderSummaryProps) {
288
303
  toFree = (p.freeShippingFrom || 75) - (sub - disc);
289
304
  return (
290
305
  <div className="tf-summary">
291
- {p.title !== false && <b className="tf-summary-title">{p.title || "Order summary"}</b>}
306
+ {p.title !== false && <b className="tf-summary-title">{p.title || t.orderSummary}</b>}
292
307
  {p.showLines && (
293
308
  <div
294
309
  className="tf-col"
@@ -313,9 +328,7 @@ export function OrderSummary(p: OrderSummaryProps) {
313
328
  )}
314
329
  {toFree > 0 && p.freeShippingFrom !== 0 && (
315
330
  <div className="tf-freeship">
316
- <span>
317
- Add <b>{money(Math.ceil(toFree * 100) / 100)}</b> for free shipping
318
- </span>
331
+ <span>{t.freeShippingIn(<b>{money(Math.ceil(toFree * 100) / 100)}</b>)}</span>
319
332
  <span className="tf-usage-track">
320
333
  <span
321
334
  className="tf-usage-fill tf-sf-mint"
@@ -339,7 +352,7 @@ export function OrderSummary(p: OrderSummaryProps) {
339
352
  p.onCoupon?.(null);
340
353
  }}
341
354
  >
342
- {"Remove"}
355
+ {t.remove}
343
356
  </button>
344
357
  </div>
345
358
  ) : (
@@ -348,14 +361,14 @@ export function OrderSummary(p: OrderSummaryProps) {
348
361
  onSubmit={(e) => {
349
362
  e.preventDefault();
350
363
  const r = p.onCoupon?.(code[0].trim().toUpperCase());
351
- err[1](r === false ? "That code isn’t valid" : null);
364
+ err[1](r === false ? t.invalidCode : null);
352
365
  if (r !== false) code[1]("");
353
366
  }}
354
367
  >
355
368
  <input
356
369
  className="tf-input"
357
- placeholder="Discount code"
358
- aria-label="Discount code"
370
+ placeholder={t.discountCode}
371
+ aria-label={t.discountCode}
359
372
  value={code[0]}
360
373
  onChange={(e) => {
361
374
  code[1](e.target.value);
@@ -364,7 +377,7 @@ export function OrderSummary(p: OrderSummaryProps) {
364
377
  aria-invalid={err[0] ? "true" : undefined}
365
378
  />
366
379
  <Button type="submit" variant="soft" disabled={!code[0].trim()}>
367
- {"Apply"}
380
+ {t.apply}
368
381
  </Button>
369
382
  {err[0] && (
370
383
  <span
@@ -380,25 +393,25 @@ export function OrderSummary(p: OrderSummaryProps) {
380
393
  ))}
381
394
  <dl className="tf-summary-rows">
382
395
  <div>
383
- <dt>{"Subtotal"}</dt>
396
+ <dt>{t.subtotal}</dt>
384
397
  <dd>{money(sub)}</dd>
385
398
  </div>
386
399
  {disc > 0 && (
387
400
  <div className="save">
388
- <dt>{"Discount"}</dt>
401
+ <dt>{t.discount}</dt>
389
402
  <dd>{"−" + money(disc)}</dd>
390
403
  </div>
391
404
  )}
392
405
  <div>
393
- <dt>{"Shipping"}</dt>
394
- <dd>{ship === 0 ? "Free" : money(ship)}</dd>
406
+ <dt>{t.shipping}</dt>
407
+ <dd>{ship === 0 ? t.free : money(ship)}</dd>
395
408
  </div>
396
409
  <div className="total">
397
- <dt>{"Total"}</dt>
410
+ <dt>{t.total}</dt>
398
411
  <dd>{money(total)}</dd>
399
412
  </div>
400
413
  <div className="vat">
401
- <dt>{`incl. ${p.vat || 19}% VAT`}</dt>
414
+ <dt>{t.inclVat(formatPercent(p.vat || 19))}</dt>
402
415
  <dd>{money(Math.round(vat * 100) / 100)}</dd>
403
416
  </div>
404
417
  </dl>
@@ -408,10 +421,11 @@ export function OrderSummary(p: OrderSummaryProps) {
408
421
  }
409
422
 
410
423
  export function CheckoutSteps(p: CheckoutStepsProps) {
411
- const steps = p.steps || ["Cart", "Details", "Shipping", "Payment"],
424
+ const t = useStrings(COMMERCE_STRINGS);
425
+ const steps = p.steps || t.checkoutSteps,
412
426
  cur = p.current || 0;
413
427
  return (
414
- <ol className="tf-csteps" aria-label="Checkout progress">
428
+ <ol className="tf-csteps" aria-label={t.checkoutProgress}>
415
429
  {steps.map((s, i) => (
416
430
  <li
417
431
  key={s}
@@ -436,18 +450,10 @@ export function CheckoutSteps(p: CheckoutStepsProps) {
436
450
 
437
451
  /* Status keys and their visible text. */
438
452
  const ORDER_STEPS = ["Placed", "Paid", "Packed", "Shipped", "Delivered"] as const;
439
- const orderStepLabel = (s: string) =>
440
- ({
441
- Placed: "Placed",
442
- Paid: "Paid",
443
- Packed: "Packed",
444
- Shipped: "Shipped",
445
- Delivered: "Delivered",
446
- Refunded: "Refunded",
447
- Cancelled: "Cancelled",
448
- })[s] ?? s;
449
453
 
450
454
  export function OrderTimeline(p: OrderTimelineProps) {
455
+ const t = useStrings(COMMERCE_STRINGS);
456
+ const orderStepLabel = (s: string) => t.orderStatus[s] ?? s;
451
457
  const cur = (ORDER_STEPS as readonly string[]).indexOf(p.status),
452
458
  times = p.times || {},
453
459
  cancelled = p.status === "Cancelled" || p.status === "Refunded";
@@ -482,33 +488,35 @@ export function OrderTimeline(p: OrderTimelineProps) {
482
488
  /* ── System screens ── */
483
489
 
484
490
  export function PlanCard(p: PlanCardProps) {
491
+ const t = useStrings(COMMERCE_STRINGS);
492
+ const money = useMoney();
485
493
  return (
486
494
  <div className={cx("tf-plan", p.featured && "featured", p.current && "current")}>
487
- {p.featured && <span className="tf-plan-flag">{p.flag || "Most popular"}</span>}
495
+ {p.featured && <span className="tf-plan-flag">{p.flag || t.mostPopular}</span>}
488
496
  <div className="tf-plan-head">
489
497
  <b>{p.name}</b>
490
498
  {p.text && <span>{p.text}</span>}
491
499
  </div>
492
500
  <div className="tf-plan-price">
493
501
  {p.price === 0 ? (
494
- <b>{"Free"}</b>
502
+ <b>{t.free}</b>
495
503
  ) : p.price == null ? (
496
- <b>{"Let’s talk"}</b>
504
+ <b>{t.letsTalk}</b>
497
505
  ) : (
498
506
  <>
499
507
  <b>{money(p.price)}</b>
500
- <span>{p.per || "per seat / month"}</span>
508
+ <span>{p.per || t.perSeatMonth}</span>
501
509
  </>
502
510
  )}
503
511
  {p.was && <s>{money(p.was)}</s>}
504
512
  </div>
505
513
  <Button
506
514
  variant={p.current ? "soft" : p.featured ? "primary" : "secondary"}
507
- disabled={p.current}
515
+ disabled={p.current || p.disabled}
508
516
  onClick={p.onPick}
509
517
  className="tf-plan-cta"
510
518
  >
511
- {p.current ? "Current plan" : p.cta || `Choose ${p.name}`}
519
+ {p.current ? t.currentPlan : p.cta || t.choosePlan(p.name)}
512
520
  </Button>
513
521
  <ul className="tf-plan-feats">
514
522
  {(p.features || []).map((f, i) => (
@@ -2,6 +2,9 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { cx, durationToken, enterDelay, reduced } from "../cx.js";
5
+ import { formatLike, parseFormatted, type FormattedNumber } from "../format.js";
6
+ import { useFormat, useStrings } from "../context.js";
7
+ import { DATA_DISPLAY_STRINGS } from "../strings.js";
5
8
  import { Icon } from "../Icon.js";
6
9
  import { useMounted, useScrollFocus } from "../hooks.js";
7
10
  import { Avatar, IconButton, TintIcon } from "./foundation.js";
@@ -26,44 +29,24 @@ import type {
26
29
  } from "./types.js";
27
30
 
28
31
  /* ── Data ── */
29
- interface ParsedNumber {
30
- pre: string;
31
- num: number;
32
- dec: number;
33
- comma: boolean;
34
- post: string;
35
- }
36
- function parseNum(v: string | number): ParsedNumber | null {
37
- const m = /^([^0-9-]*)(-?[0-9][0-9,]*\.?[0-9]*)(.*)$/.exec(String(v));
38
- if (!m) return null;
39
- const raw = (m[2] as string).replace(/,/g, ""),
40
- dec = (raw.split(".")[1] || "").length;
41
- return {
42
- pre: m[1] ?? "",
43
- num: parseFloat(raw),
44
- dec: dec,
45
- comma: (m[2] as string).indexOf(",") > -1,
46
- post: m[3] ?? "",
47
- };
48
- }
49
- export function fmt(n: number, o: ParsedNumber) {
50
- let s = n.toFixed(o.dec);
51
- if (o.comma) {
52
- const parts = s.split(".");
53
- parts[0] = (parts[0] as string).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
54
- s = parts.join(".");
55
- }
56
- return o.pre + s + o.post;
57
- }
32
+ /**
33
+ * Counts up to `value`. Strings are read in the locale of the nearest `UIProvider` ("13,05 €/m²"
34
+ * in de-DE, "$1,305.50" in en-US): the number animates, the text around it stays, and decimals and
35
+ * grouping are kept. The final text is always `value` itself.
36
+ */
58
37
  export function CountUp(p: CountUpProps) {
59
- const o = parseNum(p.value),
60
- st = React.useState(o ? fmt(0, o) : String(p.value)),
38
+ const { locale } = useFormat();
39
+ const o = parseFormatted(p.value, locale),
40
+ fmt = (n: number, shape: FormattedNumber) => formatLike(n, shape, locale),
41
+ // numbers are shown in the locale too; strings end exactly as given
42
+ final = o && typeof p.value === "number" ? fmt(p.value, o) : String(p.value),
43
+ st = React.useState(o ? fmt(0, o) : final),
61
44
  last = React.useRef(0),
62
45
  first = React.useRef(true),
63
46
  el = React.useRef<HTMLSpanElement>(null);
64
47
  React.useEffect(() => {
65
48
  if (!o || reduced(el.current)) {
66
- st[1](String(p.value));
49
+ st[1](final);
67
50
  return;
68
51
  }
69
52
  const slow = durationToken("--dur-slow", 900, el.current);
@@ -79,17 +62,20 @@ export function CountUp(p: CountUpProps) {
79
62
  if (start === null) start = t;
80
63
  const k = Math.min(1, Math.max(0, (t - start - delay) / dur));
81
64
  const e = 1 - Math.pow(1 - k, 3),
82
- v = from + (target.num - from) * e;
65
+ v = from + (target.value - from) * e;
83
66
  last.current = v;
84
67
  st[1](fmt(v, target));
85
68
  if (k < 1) raf = requestAnimationFrame(tick);
86
- else last.current = target.num;
69
+ else {
70
+ last.current = target.value;
71
+ st[1](final);
72
+ }
87
73
  }
88
74
  raf = requestAnimationFrame(tick);
89
75
  return () => {
90
76
  cancelAnimationFrame(raf);
91
77
  };
92
- }, [p.value]);
78
+ }, [p.value, locale]);
93
79
  return (
94
80
  <span ref={el} className={p.className}>
95
81
  {st[0]}
@@ -160,6 +146,7 @@ const KIND: Record<string, [IconName, Tint]> = {
160
146
  incident: ["bell", "blush"],
161
147
  };
162
148
  export function EventCard(p: EventCardProps) {
149
+ const t = useStrings(DATA_DISPLAY_STRINGS);
163
150
  const k = KIND[p.kind || "meeting"] || (KIND.meeting as [IconName, Tint]),
164
151
  tint = p.tint || k[1];
165
152
  return (
@@ -178,9 +165,9 @@ export function EventCard(p: EventCardProps) {
178
165
  </span>
179
166
  {p.actions !== false && (
180
167
  <span className="tf-row">
181
- <IconButton icon="eye" label="Open" size="sm" variant="outline" />
182
- <IconButton icon="edit" label="Edit" size="sm" variant="outline" />
183
- <IconButton icon="trash" label="Remove" size="sm" variant="outline" />
168
+ <IconButton icon="eye" label={t.open} size="sm" variant="outline" />
169
+ <IconButton icon="edit" label={t.edit} size="sm" variant="outline" />
170
+ <IconButton icon="trash" label={t.remove} size="sm" variant="outline" />
184
171
  </span>
185
172
  )}
186
173
  </div>
@@ -312,6 +299,7 @@ export function Table(p: TableProps) {
312
299
  );
313
300
  }
314
301
  export function ListRow(p: ListRowProps) {
302
+ const t = useStrings(DATA_DISPLAY_STRINGS);
315
303
  return (
316
304
  <button
317
305
  type="button"
@@ -331,7 +319,7 @@ export function ListRow(p: ListRowProps) {
331
319
  }}
332
320
  >
333
321
  <span className="tf-row-title">
334
- {p.unread && <span className="tf-unread" role="img" aria-label="Unread" />}
322
+ {p.unread && <span className="tf-unread" role="img" aria-label={t.unread} />}
335
323
  {p.title}
336
324
  </span>
337
325
  {p.subtitle && (
@@ -386,6 +374,7 @@ const FT: Record<string, [IconName, Tint]> = {
386
374
  zip: ["folder", "butter"],
387
375
  };
388
376
  export function FileRow(p: FileRowProps) {
377
+ const strings = useStrings(DATA_DISPLAY_STRINGS);
389
378
  const t = FT[p.kind || "doc"] || (FT.doc as [IconName, Tint]);
390
379
  return (
391
380
  <div className="tf-file">
@@ -399,11 +388,12 @@ export function FileRow(p: FileRowProps) {
399
388
  <div className="tf-file-name">{p.name}</div>
400
389
  <div className="tf-file-meta">{p.meta}</div>
401
390
  </div>
402
- <IconButton icon="download" label={`Download ${p.name}`} size="sm" />
391
+ <IconButton icon="download" label={strings.downloadItem(p.name)} size="sm" />
403
392
  </div>
404
393
  );
405
394
  }
406
395
  export function KanbanColumn(p: KanbanColumnProps) {
396
+ const t = useStrings(DATA_DISPLAY_STRINGS);
407
397
  return (
408
398
  <section className="tf-kanban" aria-label={p.title}>
409
399
  <div className="tf-kanban-head">
@@ -429,7 +419,7 @@ export function KanbanColumn(p: KanbanColumnProps) {
429
419
  {p.title}
430
420
  <span className="tf-kanban-count">{React.Children.count(p.children)}</span>
431
421
  </span>
432
- <IconButton icon="more" label="Column options" size="sm" />
422
+ <IconButton icon="more" label={t.columnOptions} size="sm" />
433
423
  </div>
434
424
  <Stagger
435
425
  className="tf-col"
@@ -442,12 +432,13 @@ export function KanbanColumn(p: KanbanColumnProps) {
442
432
  </Stagger>
443
433
  <button type="button" className="tf-kanban-add" onClick={p.onAdd}>
444
434
  <Icon name="plus" size={14} />
445
- {"Add card"}
435
+ {t.addCard}
446
436
  </button>
447
437
  </section>
448
438
  );
449
439
  }
450
440
  export function Progress(p: ProgressProps) {
441
+ const t = useStrings(DATA_DISPLAY_STRINGS);
451
442
  const v = Math.max(0, Math.min(100, p.value || 0));
452
443
  return (
453
444
  <div
@@ -456,7 +447,7 @@ export function Progress(p: ProgressProps) {
456
447
  aria-valuenow={v}
457
448
  aria-valuemin={0}
458
449
  aria-valuemax={100}
459
- aria-label={p.label || "Progress"}
450
+ aria-label={p.label || t.progress}
460
451
  >
461
452
  <span
462
453
  style={{
@@ -467,6 +458,8 @@ export function Progress(p: ProgressProps) {
467
458
  );
468
459
  }
469
460
  export function ProgressRing(p: ProgressRingProps) {
461
+ const t = useStrings(DATA_DISPLAY_STRINGS);
462
+ const { formatPercent } = useFormat();
470
463
  const size = p.size || 88,
471
464
  sw = p.stroke || 9,
472
465
  r = (size - sw) / 2,
@@ -481,7 +474,7 @@ export function ProgressRing(p: ProgressRingProps) {
481
474
  height: size,
482
475
  }}
483
476
  role="img"
484
- aria-label={(p.label || "Progress") + " " + v + "%"}
477
+ aria-label={(p.label || t.progress) + " " + formatPercent(v, { maximumFractionDigits: 2 })}
485
478
  >
486
479
  <svg width={size} height={size} viewBox={"0 0 " + size + " " + size}>
487
480
  <circle className="tf-arc-track" cx={size / 2} cy={size / 2} r={r} strokeWidth={sw} />
@@ -497,7 +490,7 @@ export function ProgressRing(p: ProgressRingProps) {
497
490
  </svg>
498
491
  <span className="tf-ring-label">
499
492
  <span>
500
- <CountUp value={v + "%"} delay={150} />
493
+ <CountUp value={formatPercent(v, { maximumFractionDigits: 2 })} delay={150} />
501
494
  {p.caption && <small>{p.caption}</small>}
502
495
  </span>
503
496
  </span>
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
+ import { useStrings } from "../context.js";
4
+ import { DATA_TABLE_STRINGS } from "../strings.js";
3
5
  import { Icon } from "../Icon.js";
4
6
  import { cx } from "../cx.js";
5
7
  import { SearchField } from "./inputs.js";
@@ -9,6 +11,7 @@ import type { DataTableProps, FilterBarProps, FilterDef } from "./types.js";
9
11
 
10
12
  /* ── Data table: sort, select + bulk bar, column toggles, sticky head, expandable rows ── */
11
13
  export function FilterBar(p: FilterBarProps) {
14
+ const t = useStrings(DATA_TABLE_STRINGS);
12
15
  const active = p.value || {},
13
16
  keys = Object.keys(active).filter((k) => active[k] != null && active[k] !== "");
14
17
  function set(k: string, val: string | null) {
@@ -54,7 +57,7 @@ export function FilterBar(p: FilterBarProps) {
54
57
  <button
55
58
  type="button"
56
59
  className="x"
57
- aria-label={`Remove filter ${f.label}`}
60
+ aria-label={t.removeFilter(f.label)}
58
61
  onClick={() => {
59
62
  set(k, null);
60
63
  }}
@@ -86,7 +89,7 @@ export function FilterBar(p: FilterBarProps) {
86
89
  trigger={(s) => (
87
90
  <button type="button" className="tf-chip tf-chip-dashed" onClick={s.toggle}>
88
91
  <Icon name="plus" size={14} />
89
- {"Filter"}
92
+ {t.addFilter}
90
93
  </button>
91
94
  )}
92
95
  >
@@ -109,7 +112,7 @@ export function FilterBar(p: FilterBarProps) {
109
112
  p.onChange?.({});
110
113
  }}
111
114
  >
112
- {"Clear all"}
115
+ {t.clearAll}
113
116
  </button>
114
117
  )}
115
118
  {p.right && <div className="tf-filterbar-right">{p.right}</div>}
@@ -121,6 +124,7 @@ export function AddFilterMenu(p: {
121
124
  avail: FilterDef[];
122
125
  onPick?: (key: string, option: string) => void;
123
126
  }) {
127
+ const t = useStrings(DATA_TABLE_STRINGS);
124
128
  const step = React.useState<FilterDef | null>(null);
125
129
  const cur = step[0];
126
130
  if (cur)
@@ -128,7 +132,7 @@ export function AddFilterMenu(p: {
128
132
  <Menu
129
133
  items={[
130
134
  {
131
- label: "Back",
135
+ label: t.back,
132
136
  icon: "chevronLeft",
133
137
  onClick: () => {
134
138
  step[1](null);
@@ -161,6 +165,7 @@ export function AddFilterMenu(p: {
161
165
  export function DataTable<R extends Record<string, any> = Record<string, any>>(
162
166
  p: DataTableProps<R>,
163
167
  ) {
168
+ const t = useStrings(DATA_TABLE_STRINGS);
164
169
  const cols = p.columns || [],
165
170
  rk = p.rowKey || "id";
166
171
  const sort = React.useState<{ key: string; dir: "asc" | "desc" } | null>(p.defaultSort || null),
@@ -229,11 +234,11 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
229
234
  width={220}
230
235
  flush
231
236
  trigger={(s) => (
232
- <Tooltip label="Columns">
237
+ <Tooltip label={t.columns}>
233
238
  <button
234
239
  type="button"
235
240
  className="tf-iconbtn tf-iconbtn-soft"
236
- aria-label="Choose columns"
241
+ aria-label={t.chooseColumns}
237
242
  onClick={s.toggle}
238
243
  >
239
244
  <Icon name="grid" />
@@ -242,7 +247,7 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
242
247
  )}
243
248
  >
244
249
  <div className="tf-menu tf-menu-static">
245
- <div className="tf-menu-label">{"Columns"}</div>
250
+ <div className="tf-menu-label">{t.columns}</div>
246
251
  {cols
247
252
  .filter((c) => c.hideable !== false && c.label)
248
253
  .map((c) => {
@@ -287,12 +292,12 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
287
292
  () => {
288
293
  sel[1](all ? [] : ids);
289
294
  },
290
- "Select all rows",
295
+ t.selectAllRows,
291
296
  some,
292
297
  )}
293
298
  </th>
294
299
  )}
295
- {p.renderExpanded && <th className="tf-dt-exp" aria-label="Expand" />}
300
+ {p.renderExpanded && <th className="tf-dt-exp" aria-label={t.expand} />}
296
301
  {vis.map((c) => {
297
302
  const s = sort[0] && sort[0].key === c.key ? sort[0].dir : null;
298
303
  return (
@@ -347,7 +352,7 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
347
352
  {rows.length === 0 ? (
348
353
  <tr>
349
354
  <td colSpan={span} className="tf-dt-empty">
350
- {p.empty || "Nothing matches these filters."}
355
+ {p.empty || t.nothingMatchesFilters}
351
356
  </td>
352
357
  </tr>
353
358
  ) : (
@@ -365,7 +370,7 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
365
370
  () => {
366
371
  toggle(id);
367
372
  },
368
- "Select row",
373
+ t.selectRow,
369
374
  )}
370
375
  </td>
371
376
  )}
@@ -375,7 +380,7 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
375
380
  type="button"
376
381
  className={cx("tf-dt-toggle", ex && "open")}
377
382
  aria-expanded={ex}
378
- aria-label={ex ? "Collapse row" : "Expand row"}
383
+ aria-label={ex ? t.collapseRow : t.expandRow}
379
384
  onClick={() => {
380
385
  const n = Object.assign({}, open[0]);
381
386
  n[String(id)] = !ex;
@@ -407,8 +412,8 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
407
412
  </table>
408
413
  </div>
409
414
  {p.selectable && selected.length > 0 && (
410
- <div className="tf-bulkbar" role="toolbar" aria-label="Bulk actions">
411
- <b>{`${selected.length} selected`}</b>
415
+ <div className="tf-bulkbar" role="toolbar" aria-label={t.bulkActions}>
416
+ <b>{t.selectedCount(selected.length)}</b>
412
417
  <button
413
418
  type="button"
414
419
  className="tf-linkbtn on-frame"
@@ -416,7 +421,7 @@ export function DataTable<R extends Record<string, any> = Record<string, any>>(
416
421
  sel[1]([]);
417
422
  }}
418
423
  >
419
- {"Clear"}
424
+ {t.clear}
420
425
  </button>
421
426
  <div className="tf-bulkbar-actions">
422
427
  {(p.bulkActions || []).map((a) => (