@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
@@ -3,7 +3,9 @@ import * as React from "react";
3
3
  import { leaving, usePresence } from "../presence.js";
4
4
  import { cx } from "../cx.js";
5
5
  import { useFieldError } from "./form.js";
6
- import { formatDate, formatNumber, WEEK_START } from "../format.js";
6
+ import { formatDate, WEEK_START } from "../format.js";
7
+ import { useFormat, useStrings, type Formatters } from "../context.js";
8
+ import { FORMS_STRINGS } from "../strings.js";
7
9
  import { Icon } from "../Icon.js";
8
10
  import { useControlled, useId, useOutside } from "../hooks.js";
9
11
  import { Avatar, IconButton, TintIcon } from "./foundation.js";
@@ -101,6 +103,15 @@ export function TextField(p: TextFieldProps) {
101
103
  onBlur={p.onBlur}
102
104
  required={p.required}
103
105
  disabled={p.disabled}
106
+ readOnly={p.readOnly}
107
+ min={p.min}
108
+ max={p.max}
109
+ step={p.step}
110
+ minLength={p.minLength}
111
+ maxLength={p.maxLength}
112
+ pattern={p.pattern}
113
+ inputMode={p.inputMode}
114
+ autoFocus={p.autoFocus}
104
115
  aria-invalid={error ? "true" : undefined}
105
116
  aria-describedby={error || p.hint ? `${id}-msg` : undefined}
106
117
  autoComplete={p.autoComplete}
@@ -223,6 +234,7 @@ export function norm(o: string | Option): Option {
223
234
  }
224
235
 
225
236
  export function Combobox(p: ComboboxProps) {
237
+ const t = useStrings(FORMS_STRINGS);
226
238
  const multi = !!p.multiple,
227
239
  opts = (p.options || []).map(norm),
228
240
  id = useId("cb");
@@ -310,7 +322,7 @@ export function Combobox(p: ComboboxProps) {
310
322
  {labelOf(val)}
311
323
  <button
312
324
  type="button"
313
- aria-label={`Remove ${labelOf(val)}`}
325
+ aria-label={t.removeItem(labelOf(val))}
314
326
  onClick={(e) => {
315
327
  e.stopPropagation();
316
328
  v[1](selList.filter((x) => x !== val));
@@ -336,7 +348,7 @@ export function Combobox(p: ComboboxProps) {
336
348
  : p.placeholder
337
349
  : selOne != null && !q[0]
338
350
  ? labelOf(selOne)
339
- : p.placeholder || "Select…"
351
+ : p.placeholder || t.selectPlaceholder
340
352
  }
341
353
  value={q[0]}
342
354
  onChange={(e) => {
@@ -368,9 +380,7 @@ export function Combobox(p: ComboboxProps) {
368
380
  role="listbox"
369
381
  aria-multiselectable={multi || undefined}
370
382
  >
371
- {list.length === 0 && !canCreate && (
372
- <div className="tf-combo-empty">{"No matches"}</div>
373
- )}
383
+ {list.length === 0 && !canCreate && <div className="tf-combo-empty">{t.noMatches}</div>}
374
384
  {list.map((o, i) => (
375
385
  <div
376
386
  key={o.value}
@@ -411,7 +421,7 @@ export function Combobox(p: ComboboxProps) {
411
421
  }}
412
422
  >
413
423
  <Icon name="plus" size={15} />
414
- <span className="tf-option-text">{`Create “${q[0].trim()}”`}</span>
424
+ <span className="tf-option-text">{t.createOption(q[0].trim())}</span>
415
425
  </div>
416
426
  )}
417
427
  </div>
@@ -422,6 +432,7 @@ export function Combobox(p: ComboboxProps) {
422
432
  }
423
433
 
424
434
  export function TagInput(p: TagInputProps) {
435
+ const strings = useStrings(FORMS_STRINGS);
425
436
  const v = useControlled(p.value, p.defaultValue || [], p.onChange),
426
437
  q = React.useState(""),
427
438
  id = useId("tag");
@@ -444,7 +455,7 @@ export function TagInput(p: TagInputProps) {
444
455
  {t}
445
456
  <button
446
457
  type="button"
447
- aria-label={`Remove ${t}`}
458
+ aria-label={strings.removeItem(t)}
448
459
  onClick={() => {
449
460
  v[1](v[0].filter((x) => x !== t));
450
461
  }}
@@ -456,7 +467,7 @@ export function TagInput(p: TagInputProps) {
456
467
  <input
457
468
  id={id}
458
469
  className="tf-combo-input"
459
- placeholder={v[0].length ? "" : p.placeholder || "Type and press Enter"}
470
+ placeholder={v[0].length ? "" : p.placeholder || strings.tagPlaceholder}
460
471
  value={q[0]}
461
472
  onChange={(e) => {
462
473
  const s = e.target.value;
@@ -479,21 +490,30 @@ export function TagInput(p: TagInputProps) {
479
490
  }
480
491
 
481
492
  /* ── Date picker (single or range, with presets) ── */
482
- function monthName(d: Date) {
483
- return formatDate(d, {
484
- month: "long",
485
- year: "numeric",
486
- });
493
+ function monthName(d: Date, locale?: string) {
494
+ return formatDate(
495
+ d,
496
+ {
497
+ month: "long",
498
+ year: "numeric",
499
+ },
500
+ locale,
501
+ );
487
502
  }
488
503
 
489
- export function weekdays() {
504
+ /** Short weekday names from the first day of the week (`locale` default en-US). */
505
+ export function weekdays(locale?: string) {
490
506
  const out: string[] = [];
491
507
  for (let i = 0; i < 7; i++) {
492
508
  const d = new Date(2024, 0, 1 + ((WEEK_START + i - 1) % 7));
493
509
  out.push(
494
- formatDate(d, {
495
- weekday: "short",
496
- }),
510
+ formatDate(
511
+ d,
512
+ {
513
+ weekday: "short",
514
+ },
515
+ locale,
516
+ ),
497
517
  );
498
518
  }
499
519
  return out;
@@ -513,8 +533,8 @@ function same(a: Date | null | undefined, b: Date | null | undefined): boolean {
513
533
  );
514
534
  }
515
535
 
516
- function fmtD(d: Date | null | undefined) {
517
- return d ? formatDate(d) : "";
536
+ function fmtD(d: Date | null | undefined, locale?: string) {
537
+ return d ? formatDate(d, undefined, locale) : "";
518
538
  }
519
539
 
520
540
  function addD(d: Date, n: number) {
@@ -524,6 +544,8 @@ function addD(d: Date, n: number) {
524
544
  }
525
545
 
526
546
  export function Calendar(p: CalendarProps) {
547
+ const t = useStrings(FORMS_STRINGS);
548
+ const { locale } = useFormat();
527
549
  const range = p.range ? ((p.value as DateRange | null | undefined) ?? null) : null;
528
550
  const today = d0(p.today || new Date()),
529
551
  sv = p.range ? range && range.from : (p.value as Date | null | undefined),
@@ -544,17 +566,17 @@ export function Calendar(p: CalendarProps) {
544
566
  <div className="tf-cal-head">
545
567
  <IconButton
546
568
  icon="chevronLeft"
547
- label="Previous month"
569
+ label={t.previousMonth}
548
570
  size="sm"
549
571
  variant="soft"
550
572
  onClick={() => {
551
573
  move(-1);
552
574
  }}
553
575
  />
554
- <b>{monthName(first)}</b>
576
+ <b>{monthName(first, locale)}</b>
555
577
  <IconButton
556
578
  icon="chevronRight"
557
- label="Next month"
579
+ label={t.nextMonth}
558
580
  size="sm"
559
581
  variant="soft"
560
582
  onClick={() => {
@@ -562,8 +584,8 @@ export function Calendar(p: CalendarProps) {
562
584
  }}
563
585
  />
564
586
  </div>
565
- <div className="tf-cal-grid" role="group" aria-label={monthName(first)}>
566
- {weekdays().map((w, i) => (
587
+ <div className="tf-cal-grid" role="group" aria-label={monthName(first, locale)}>
588
+ {weekdays(locale).map((w, i) => (
567
589
  <span key={i} className="tf-cal-wd">
568
590
  {w}
569
591
  </span>
@@ -589,7 +611,7 @@ export function Calendar(p: CalendarProps) {
589
611
  isTo && from && "end",
590
612
  )}
591
613
  aria-pressed={isFrom || isTo}
592
- aria-label={formatDate(d, { day: "numeric", month: "long", year: "numeric" })}
614
+ aria-label={formatDate(d, { day: "numeric", month: "long", year: "numeric" }, locale)}
593
615
  onPointerEnter={() => {
594
616
  p.range && hov[1](d);
595
617
  }}
@@ -617,6 +639,8 @@ export function Calendar(p: CalendarProps) {
617
639
  }
618
640
 
619
641
  export function DatePicker(p: DatePickerProps) {
642
+ const t = useStrings(FORMS_STRINGS);
643
+ const { locale } = useFormat();
620
644
  const id = useId("dp"),
621
645
  v = useControlled(
622
646
  p.value,
@@ -633,18 +657,18 @@ export function DatePicker(p: DatePickerProps) {
633
657
  const presets =
634
658
  p.presets !== false && p.range
635
659
  ? [
636
- ["Last 7 days", -6],
637
- ["Last 30 days", -29],
638
- ["Last 90 days", -89],
639
- ["This year", "y"],
660
+ [t.lastDays(7), -6],
661
+ [t.lastDays(30), -29],
662
+ [t.lastDays(90), -89],
663
+ [t.thisYear, "y"],
640
664
  ]
641
665
  : null;
642
666
  const rv = p.range ? ((v[0] as DateRange | null) ?? null) : null;
643
667
  const txt = p.range
644
668
  ? rv && rv.from
645
- ? fmtD(rv.from) + " – " + (rv.to ? fmtD(rv.to) : "…")
669
+ ? fmtD(rv.from, locale) + " – " + (rv.to ? fmtD(rv.to, locale) : "…")
646
670
  : ""
647
- : fmtD(v[0] as Date | null);
671
+ : fmtD(v[0] as Date | null, locale);
648
672
  return (
649
673
  <FormField
650
674
  id={id}
@@ -667,7 +691,7 @@ export function DatePicker(p: DatePickerProps) {
667
691
  onClick={s.toggle}
668
692
  >
669
693
  <Icon name="calendar" size={15} />
670
- <span>{txt || p.placeholder || (p.range ? "Pick a range" : "Pick a date")}</span>
694
+ <span>{txt || p.placeholder || (p.range ? t.pickRange : t.pickDate)}</span>
671
695
  </button>
672
696
  )}
673
697
  >
@@ -722,6 +746,7 @@ export function DatePicker(p: DatePickerProps) {
722
746
 
723
747
  /* ── Number, slider, OTP, upload ── */
724
748
  export function NumberInput(p: NumberInputProps) {
749
+ const t = useStrings(FORMS_STRINGS);
725
750
  const id = useId("num"),
726
751
  v = useControlled(p.value, p.defaultValue != null ? p.defaultValue : 0, p.onChange),
727
752
  step = p.step || 1;
@@ -735,7 +760,7 @@ export function NumberInput(p: NumberInputProps) {
735
760
  <div className="tf-number">
736
761
  <IconButton
737
762
  icon="minus"
738
- label="Decrease"
763
+ label={t.decrease}
739
764
  size="sm"
740
765
  disabled={p.min != null && v[0] <= p.min}
741
766
  onClick={() => {
@@ -766,7 +791,7 @@ export function NumberInput(p: NumberInputProps) {
766
791
  {p.unit && <span className="tf-number-unit">{p.unit}</span>}
767
792
  <IconButton
768
793
  icon="plus"
769
- label="Increase"
794
+ label={t.increase}
770
795
  size="sm"
771
796
  disabled={p.max != null && v[0] >= p.max}
772
797
  onClick={() => {
@@ -827,6 +852,7 @@ export function Slider(p: SliderProps) {
827
852
  }
828
853
 
829
854
  export function OtpInput(p: OtpInputProps) {
855
+ const t = useStrings(FORMS_STRINGS);
830
856
  const n = p.length || 6,
831
857
  st = React.useState(Array(n).fill("")),
832
858
  refs = React.useRef<(HTMLInputElement | null)[]>([]);
@@ -841,7 +867,7 @@ export function OtpInput(p: OtpInputProps) {
841
867
  <div
842
868
  className={cx("tf-otp", p.error && "invalid")}
843
869
  role="group"
844
- aria-label={p.label || "Verification code"}
870
+ aria-label={p.label || t.verificationCode}
845
871
  >
846
872
  {st[0].map((c, i) => (
847
873
  <React.Fragment key={i}>
@@ -855,7 +881,7 @@ export function OtpInput(p: OtpInputProps) {
855
881
  autoComplete={i === 0 ? "one-time-code" : "off"}
856
882
  maxLength={1}
857
883
  value={c}
858
- aria-label={`Digit ${i + 1}`}
884
+ aria-label={t.digitN(i + 1)}
859
885
  onChange={(e) => {
860
886
  set(i, e.target.value.replace(/\D/g, "").slice(-1));
861
887
  }}
@@ -882,26 +908,65 @@ export function OtpInput(p: OtpInputProps) {
882
908
  );
883
909
  }
884
910
 
885
- function fsize(b: number) {
911
+ function fsize(b: number, f: Pick<Formatters, "formatNumber">) {
886
912
  return b > 1048576
887
- ? `${formatNumber(b / 1048576, { maximumFractionDigits: 1 })} MB`
888
- : `${formatNumber(Math.max(1, Math.round(b / 1024)))} KB`;
913
+ ? `${f.formatNumber(b / 1048576, { maximumFractionDigits: 1 })} MB`
914
+ : `${f.formatNumber(Math.max(1, Math.round(b / 1024)))} KB`;
915
+ }
916
+
917
+ /** Whether a file matches an `accept` list ("image/*,.pdf,application/pdf"); empty accepts all. */
918
+ function acceptsFile(file: { name: string; type: string }, accept?: string): boolean {
919
+ const rules = (accept || "")
920
+ .split(",")
921
+ .map((r) => r.trim().toLowerCase())
922
+ .filter(Boolean);
923
+ if (!rules.length) return true;
924
+ const name = file.name.toLowerCase(),
925
+ type = (file.type || "").toLowerCase();
926
+ return rules.some((r) =>
927
+ r.startsWith(".")
928
+ ? name.endsWith(r)
929
+ : r.endsWith("/*")
930
+ ? type.startsWith(r.slice(0, -1))
931
+ : type === r,
932
+ );
889
933
  }
890
934
 
891
935
  export function FileDrop(p: FileDropProps) {
936
+ const t = useStrings(FORMS_STRINGS);
937
+ const fm = useFormat();
892
938
  const over = React.useState(false),
893
939
  files = useControlled(p.files, [], p.onChange),
894
- inp = React.useRef<HTMLInputElement>(null);
940
+ inp = React.useRef<HTMLInputElement>(null),
941
+ timers = React.useRef<ReturnType<typeof setInterval>[]>([]);
942
+ // Simulated progress is a demo of the look; with `onFiles` the app reports real progress, so
943
+ // an uncontrolled list without simulation shows no progress at all.
944
+ const simulate = p.simulateProgress ?? !p.onFiles,
945
+ tracked = simulate || p.files !== undefined;
946
+ React.useEffect(
947
+ () => () => {
948
+ timers.current.forEach(clearInterval);
949
+ },
950
+ [],
951
+ );
895
952
  function take(list: FileList | null) {
896
- const add: UploadFile[] = Array.from(list || []).map((f) => ({
953
+ let picked = Array.from(list || []);
954
+ const rejected = picked.filter((f) => !acceptsFile(f, p.accept));
955
+ picked = picked.filter((f) => acceptsFile(f, p.accept));
956
+ if (p.multiple === false) picked = picked.slice(0, 1);
957
+ if (rejected.length) p.onReject?.(rejected);
958
+ if (!picked.length) return;
959
+ p.onFiles?.(picked);
960
+ const add: UploadFile[] = picked.map((f) => ({
897
961
  name: f.name,
898
962
  size: f.size,
899
963
  progress: 0,
964
+ file: f,
900
965
  id: Math.random().toString(36).slice(2),
901
966
  }));
902
- if (!add.length) return;
903
967
  const all = (p.multiple === false ? [] : files[0]).concat(add);
904
968
  files[1](all);
969
+ if (!simulate) return;
905
970
  add.forEach((f, k) => {
906
971
  let t = 0;
907
972
  const iv = setInterval(
@@ -913,6 +978,7 @@ export function FileDrop(p: FileDropProps) {
913
978
  },
914
979
  160 + k * 40,
915
980
  );
981
+ timers.current.push(iv);
916
982
  });
917
983
  }
918
984
  return (
@@ -949,8 +1015,8 @@ export function FileDrop(p: FileDropProps) {
949
1015
  }}
950
1016
  >
951
1017
  <TintIcon icon="upload" tint={over[0] ? "mint" : "sky"} size={40} />
952
- <b>{over[0] ? "Drop to upload" : p.title || "Drop files here or browse"}</b>
953
- <span>{p.hint || "PDF, PNG or JPG up to 10 MB"}</span>
1018
+ <b>{over[0] ? t.dropToUpload : p.title || t.dropFiles}</b>
1019
+ <span>{p.hint || t.dropHint}</span>
954
1020
  <input
955
1021
  ref={inp}
956
1022
  type="file"
@@ -971,19 +1037,30 @@ export function FileDrop(p: FileDropProps) {
971
1037
  }}
972
1038
  >
973
1039
  {files[0].map((f) => {
974
- const done = f.progress >= 100;
1040
+ // untracked: a file that is only listed (the app uploads it, or not yet)
1041
+ const pending = tracked && f.progress < 100,
1042
+ done = tracked && f.progress >= 100;
975
1043
  return (
976
1044
  <div key={f.id} className="tf-upload">
977
- <TintIcon icon="file" tint={done ? "mint" : "sky"} />
1045
+ <TintIcon icon="file" tint={pending ? "sky" : "mint"} />
978
1046
  <div className="tf-upload-text">
979
1047
  <b>{f.name}</b>
980
1048
  <span>
981
1049
  {done
982
- ? `${fsize(f.size)} · uploaded`
983
- : Math.round(f.progress) + "% · " + fsize(f.size)}
1050
+ ? t.uploaded(fsize(f.size, fm))
1051
+ : pending
1052
+ ? fm.formatPercent(Math.round(f.progress)) + " · " + fsize(f.size, fm)
1053
+ : fsize(f.size, fm)}
984
1054
  </span>
985
- {!done && (
986
- <span className="tf-progress">
1055
+ {pending && (
1056
+ <span
1057
+ className="tf-progress"
1058
+ role="progressbar"
1059
+ aria-label={f.name}
1060
+ aria-valuemin={0}
1061
+ aria-valuemax={100}
1062
+ aria-valuenow={Math.round(f.progress)}
1063
+ >
987
1064
  <span
988
1065
  style={{
989
1066
  width: f.progress + "%",
@@ -993,11 +1070,12 @@ export function FileDrop(p: FileDropProps) {
993
1070
  )}
994
1071
  </div>
995
1072
  <IconButton
996
- icon={done ? "trash" : "x"}
997
- label={done ? `Remove ${f.name}` : "Cancel upload"}
1073
+ icon={pending ? "x" : "trash"}
1074
+ label={pending ? t.cancelUpload : t.removeItem(f.name)}
998
1075
  size="sm"
999
1076
  onClick={() => {
1000
1077
  files[1](files[0].filter((x) => x.id !== f.id));
1078
+ p.onRemove?.(f);
1001
1079
  }}
1002
1080
  />
1003
1081
  </div>
@@ -28,60 +28,47 @@ function slot(
28
28
  });
29
29
  }
30
30
 
31
- export function Button(p: ButtonProps) {
32
- const v = p.variant || "primary";
33
- const cls = cx("tf-btn", "tf-btn-" + v, p.size === "sm" && "tf-btn-sm", p.className);
31
+ /** Every native `<button>` attribute, on top of the documented props. */
32
+ type NativeRest<P> = P & Omit<React.ComponentPropsWithRef<"button">, keyof P>;
33
+
34
+ export function Button(props: NativeRest<ButtonProps>) {
35
+ const { variant, size, icon, iconRight, asChild, className, children, type, ...native } = props;
36
+ const v = variant || "primary";
37
+ const cls = cx("tf-btn", "tf-btn-" + v, size === "sm" && "tf-btn-sm", className);
34
38
  const content = (inner: React.ReactNode) => (
35
39
  <>
36
- {p.icon && <Icon name={p.icon} size={15} />}
40
+ {icon && <Icon name={icon} size={15} />}
37
41
  {inner}
38
- {p.iconRight && <Icon name={p.iconRight} size={15} />}
42
+ {iconRight && <Icon name={iconRight} size={15} />}
39
43
  </>
40
44
  );
41
- if (p.asChild) {
42
- const el = slot(React.Children.only(p.children), cls, { style: p.style }, content);
45
+ if (asChild) {
46
+ const el = slot(React.Children.only(children), cls, native, content);
43
47
  if (el) return el;
44
48
  }
45
49
  return (
46
- <button
47
- type={p.type || "button"}
48
- className={cls}
49
- style={p.style}
50
- disabled={p.disabled}
51
- onClick={p.onClick}
52
- >
53
- {content(p.children)}
50
+ <button {...native} type={type || "button"} className={cls}>
51
+ {content(children)}
54
52
  </button>
55
53
  );
56
54
  }
57
55
 
58
- export function IconButton(p: IconButtonProps) {
56
+ export function IconButton(props: NativeRest<IconButtonProps>) {
57
+ const { icon, label, variant, size, asChild, className, children, type, ...native } = props;
59
58
  const cls = cx(
60
59
  "tf-iconbtn",
61
- "tf-iconbtn-" + (p.variant || "soft"),
62
- p.size && "tf-iconbtn-" + p.size,
63
- p.className,
60
+ "tf-iconbtn-" + (variant || "soft"),
61
+ size && "tf-iconbtn-" + size,
62
+ className,
64
63
  );
65
- if (p.asChild && p.children) {
66
- const el = slot(
67
- React.Children.only(p.children),
68
- cls,
69
- { "aria-label": p.label, title: p.label, style: p.style },
70
- () => <Icon name={p.icon} />,
71
- );
64
+ const named = { "aria-label": label, title: label, ...native };
65
+ if (asChild && children) {
66
+ const el = slot(React.Children.only(children), cls, named, () => <Icon name={icon} />);
72
67
  if (el) return el;
73
68
  }
74
69
  return (
75
- <button
76
- type="button"
77
- aria-label={p.label}
78
- title={p.label}
79
- className={cls}
80
- style={p.style}
81
- disabled={p.disabled}
82
- onClick={p.onClick}
83
- >
84
- <Icon name={p.icon} />
70
+ <button {...named} type={type || "button"} className={cls}>
71
+ <Icon name={icon} />
85
72
  </button>
86
73
  );
87
74
  }
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { cx } from "../cx.js";
3
+ import { useStrings } from "../context.js";
3
4
  import { Icon } from "../Icon.js";
4
5
  import { useId } from "../hooks.js";
5
6
  import { useFieldError } from "./form.js";
@@ -127,13 +128,14 @@ export function Textarea(p: TextareaProps) {
127
128
  }
128
129
 
129
130
  export function SearchField(p: SearchFieldProps) {
131
+ const t = useStrings();
130
132
  return (
131
133
  <label className={cx("tf-search", p.className)} style={p.style}>
132
134
  <Icon name="search" size={15} />
133
135
  <input
134
136
  type="search"
135
- placeholder={p.placeholder || "Search"}
136
- aria-label={p.placeholder || "Search"}
137
+ placeholder={p.placeholder || t.search}
138
+ aria-label={p.label || p.placeholder || t.search}
137
139
  value={p.value}
138
140
  onChange={p.onChange}
139
141
  />
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
+ import { useStrings } from "../context.js";
4
+ import { MAIL_STRINGS } from "../strings.js";
3
5
  import { Icon } from "../Icon.js";
4
6
  import { cx } from "../cx.js";
5
7
  import { useControlled } from "../hooks.js";
@@ -20,6 +22,7 @@ import type {
20
22
 
21
23
  /* ── Mail ── */
22
24
  export function AccountSwitcher(p: AccountSwitcherProps) {
25
+ const t = useStrings(MAIL_STRINGS);
23
26
  const q = React.useState(""),
24
27
  list = (p.accounts || []).filter(
25
28
  (a) => !q[0] || a.address.toLowerCase().indexOf(q[0].toLowerCase()) > -1,
@@ -38,9 +41,9 @@ export function AccountSwitcher(p: AccountSwitcherProps) {
38
41
  >
39
42
  <span className="tf-option-text">
40
43
  <b className={cur ? "tf-mono" : undefined}>
41
- {cur ? cur.address : p.allLabel || "All inboxes"}
44
+ {cur ? cur.address : p.allLabel || t.allInboxes}
42
45
  </b>
43
- <small>{cur ? cur.domain : `${(p.accounts || []).length} addresses`}</small>
46
+ <small>{cur ? cur.domain : t.addressCount((p.accounts || []).length)}</small>
44
47
  </span>
45
48
  <Icon name="chevronDown" size={14} />
46
49
  </button>
@@ -54,7 +57,7 @@ export function AccountSwitcher(p: AccountSwitcherProps) {
54
57
  }}
55
58
  >
56
59
  <SearchField
57
- placeholder="Find an address"
60
+ placeholder={t.findAddress}
58
61
  value={q[0]}
59
62
  onChange={(e) => {
60
63
  q[1](e.target.value);
@@ -70,7 +73,7 @@ export function AccountSwitcher(p: AccountSwitcherProps) {
70
73
  }}
71
74
  >
72
75
  <Icon name="inbox" size={15} />
73
- <span className="tf-option-text">{p.allLabel || "All inboxes"}</span>
76
+ <span className="tf-option-text">{p.allLabel || t.allInboxes}</span>
74
77
  {p.unread != null && <span className="tf-nav-count">{p.unread}</span>}
75
78
  </button>
76
79
  {list.map((a) => (
@@ -98,6 +101,7 @@ export function AccountSwitcher(p: AccountSwitcherProps) {
98
101
  }
99
102
 
100
103
  export function InboxList(p: InboxListProps) {
104
+ const t = useStrings(MAIL_STRINGS);
101
105
  return (
102
106
  <div className="tf-inboxlist" role="list">
103
107
  {(p.items || []).map((m) => (
@@ -113,7 +117,7 @@ export function InboxList(p: InboxListProps) {
113
117
  <span
114
118
  className="tf-mailrow-dot"
115
119
  role={m.unread ? "img" : undefined}
116
- aria-label={m.unread ? "Unread" : undefined}
120
+ aria-label={m.unread ? t.unread : undefined}
117
121
  />
118
122
  <span className="tf-mailrow-main">
119
123
  <span className="tf-mailrow-top">
@@ -194,13 +198,14 @@ export function MailThread(p: MailThreadProps) {
194
198
  }
195
199
 
196
200
  export function SignatureEditor(p: SignatureEditorProps) {
201
+ const t = useStrings(MAIL_STRINGS);
197
202
  const v = useControlled<Record<string, string>>(p.value, p.defaultValue || {}, p.onChange),
198
203
  fields = p.fields || [
199
- { key: "name", label: "Name" },
200
- { key: "role", label: "Role" },
201
- { key: "company", label: "Company" },
202
- { key: "phone", label: "Phone" },
203
- { key: "website", label: "Website" },
204
+ { key: "name", label: t.colName },
205
+ { key: "role", label: t.role },
206
+ { key: "company", label: t.company },
207
+ { key: "phone", label: t.phone },
208
+ { key: "website", label: t.website },
204
209
  ];
205
210
  function set(k: string, x: string) {
206
211
  const n: Record<string, string> = Object.assign({}, v[0]);
@@ -234,7 +239,7 @@ export function SignatureEditor(p: SignatureEditorProps) {
234
239
  padding: 0,
235
240
  }}
236
241
  >
237
- {p.previewLabel || "Preview"}
242
+ {p.previewLabel || t.preview}
238
243
  </span>
239
244
  <div className="tf-paper tf-sig-paper">
240
245
  <p>—</p>