@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
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
+ import { useStrings } from "../context.js";
4
+ import { PREVIEWS_STRINGS } from "../strings.js";
3
5
  import { useControlled } from "../hooks.js";
4
6
  import { cx } from "../cx.js";
5
7
  import { Avatar } from "./foundation.js";
@@ -10,6 +12,7 @@ import type { EmailPreviewProps, SearchAdPreviewProps } from "./types.js";
10
12
 
11
13
  /* ── Email and ad previews ── */
12
14
  export function EmailPreview(p: EmailPreviewProps) {
15
+ const t = useStrings(PREVIEWS_STRINGS);
13
16
  const dev = useControlled<string>(p.device, "desktop", p.onDevice);
14
17
  return (
15
18
  <div className="tf-mailprev">
@@ -19,7 +22,7 @@ export function EmailPreview(p: EmailPreviewProps) {
19
22
  <div className="tf-option-text">
20
23
  <b>{p.fromName || p.from}</b>
21
24
  <span className="tf-mailprev-subj">
22
- <b>{p.subject || "(No subject)"}</b>
25
+ <b>{p.subject || t.noSubject}</b>
23
26
  {p.preheader && <span>{" — " + p.preheader}</span>}
24
27
  </span>
25
28
  </div>
@@ -28,15 +31,15 @@ export function EmailPreview(p: EmailPreviewProps) {
28
31
  size="sm"
29
32
  value={dev[0]}
30
33
  onChange={dev[1]}
31
- label="Device"
34
+ label={t.device}
32
35
  options={[
33
36
  {
34
37
  value: "desktop",
35
- label: "Desktop",
38
+ label: t.desktop,
36
39
  },
37
40
  {
38
41
  value: "mobile",
39
- label: "Mobile",
42
+ label: t.mobile,
40
43
  },
41
44
  ]}
42
45
  />
@@ -44,7 +47,7 @@ export function EmailPreview(p: EmailPreviewProps) {
44
47
  <div className={cx("tf-mailprev-stage", dev[0])}>
45
48
  <div className="tf-mailprev-frame">
46
49
  {p.html != null ? (
47
- <iframe title="Email preview" sandbox="" srcDoc={p.html} />
50
+ <iframe title={t.emailPreview} sandbox="" srcDoc={p.html} />
48
51
  ) : (
49
52
  <div className="tf-mailprev-body">{p.children}</div>
50
53
  )}
@@ -62,6 +65,7 @@ export function EmailPreview(p: EmailPreviewProps) {
62
65
  }
63
66
 
64
67
  export function SearchAdPreview(p: SearchAdPreviewProps) {
68
+ const t = useStrings(PREVIEWS_STRINGS);
65
69
  const dev = useControlled<string>(p.device, "mobile", p.onDevice),
66
70
  hl = (p.headlines || []).filter(Boolean).slice(0, 3),
67
71
  ds = (p.descriptions || []).filter(Boolean).slice(0, 2);
@@ -75,9 +79,7 @@ export function SearchAdPreview(p: SearchAdPreviewProps) {
75
79
  (p[x[0]] || []).forEach((s, i) => {
76
80
  if (s && s.length > x[1])
77
81
  warn.push(
78
- `${
79
- x[0] === "headlines" ? "Headline" : x[0] === "descriptions" ? "Description" : "Path"
80
- } ${i + 1} is ${s.length} characters, the limit is ${x[1]}`,
82
+ t.adTooLong(x[0] as "headlines" | "descriptions" | "paths", i + 1, s.length, x[1]),
81
83
  );
82
84
  });
83
85
  });
@@ -101,21 +103,21 @@ export function SearchAdPreview(p: SearchAdPreviewProps) {
101
103
  padding: 0,
102
104
  }}
103
105
  >
104
- {"Preview"}
106
+ {t.preview}
105
107
  </span>
106
108
  <Segmented
107
109
  size="sm"
108
110
  value={dev[0]}
109
111
  onChange={dev[1]}
110
- label="Device"
112
+ label={t.device}
111
113
  options={[
112
114
  {
113
115
  value: "mobile",
114
- label: "Mobile",
116
+ label: t.mobile,
115
117
  },
116
118
  {
117
119
  value: "desktop",
118
- label: "Desktop",
120
+ label: t.desktop,
119
121
  },
120
122
  ]}
121
123
  />
@@ -129,7 +131,7 @@ export function SearchAdPreview(p: SearchAdPreviewProps) {
129
131
  <b>{p.name || p.domain}</b>
130
132
  <small>{url}</small>
131
133
  </div>
132
- <span className="tf-adprev-tag">{p.sponsoredLabel || "Sponsored"}</span>
134
+ <span className="tf-adprev-tag">{p.sponsoredLabel || t.sponsored}</span>
133
135
  </div>
134
136
  <a
135
137
  className="tf-adprev-hl"
@@ -138,9 +140,9 @@ export function SearchAdPreview(p: SearchAdPreviewProps) {
138
140
  e.preventDefault();
139
141
  }}
140
142
  >
141
- {hl.join(" | ") || "Your headline"}
143
+ {hl.join(" | ") || t.yourHeadline}
142
144
  </a>
143
- <p className="tf-adprev-desc">{ds.join(" ") || "Your description"}</p>
145
+ <p className="tf-adprev-desc">{ds.join(" ") || t.yourDescription}</p>
144
146
  {p.sitelinks && p.sitelinks.length > 0 && (
145
147
  <div className="tf-adprev-links">
146
148
  {p.sitelinks.map((s, i) => (
@@ -8,6 +8,8 @@ import * as React from "react";
8
8
  import { leaving, usePresence } from "../presence.js";
9
9
  import { Icon } from "../Icon.js";
10
10
  import { cx } from "../cx.js";
11
+ import { useStrings } from "../context.js";
12
+ import { PRIMITIVES_STRINGS } from "../strings.js";
11
13
  import { moveFocus, useControlled, useId, useKeepInView } from "../hooks.js";
12
14
  import type {
13
15
  AccordionProps,
@@ -273,6 +275,7 @@ export function HoverCard(p: HoverCardProps) {
273
275
 
274
276
  /* ── Carousel ── */
275
277
  export function Carousel(p: CarouselProps) {
278
+ const t = useStrings(PRIMITIVES_STRINGS);
276
279
  const slides = p.slides || [];
277
280
  const n = slides.length;
278
281
  const idx = useControlled(p.index, p.defaultIndex || 0, p.onChange);
@@ -332,8 +335,8 @@ export function Carousel(p: CarouselProps) {
332
335
  key={i}
333
336
  className="tf-carousel-slide"
334
337
  role="group"
335
- aria-roledescription="slide"
336
- aria-label={`${i + 1} of ${n}`}
338
+ aria-roledescription={t.slide}
339
+ aria-label={t.slideOf(i + 1, n)}
337
340
  >
338
341
  {s}
339
342
  </div>
@@ -343,7 +346,7 @@ export function Carousel(p: CarouselProps) {
343
346
  <button
344
347
  type="button"
345
348
  className="tf-iconbtn tf-iconbtn-soft tf-iconbtn-sm"
346
- aria-label="Previous slide"
349
+ aria-label={t.previousSlide}
347
350
  disabled={!p.loop && idx[0] <= 0}
348
351
  onClick={() => go(idx[0] - 1)}
349
352
  >
@@ -356,7 +359,7 @@ export function Carousel(p: CarouselProps) {
356
359
  key={i}
357
360
  type="button"
358
361
  className="tf-carousel-dot"
359
- aria-label={`Go to slide ${i + 1}`}
362
+ aria-label={t.goToSlide(i + 1)}
360
363
  aria-current={i === idx[0] ? "true" : undefined}
361
364
  onClick={() => go(i)}
362
365
  />
@@ -366,7 +369,7 @@ export function Carousel(p: CarouselProps) {
366
369
  <button
367
370
  type="button"
368
371
  className="tf-iconbtn tf-iconbtn-soft tf-iconbtn-sm"
369
- aria-label="Next slide"
372
+ aria-label={t.nextSlide}
370
373
  disabled={!p.loop && idx[0] >= last}
371
374
  onClick={() => go(idx[0] + 1)}
372
375
  >
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
3
  import { Icon } from "../Icon.js";
4
- import { formatMoney, formatNumber } from "../format.js";
4
+ import { useFormat, useStrings, type Formatters } from "../context.js";
5
+ import { REAL_ESTATE_STRINGS, type UIStrings } from "../strings.js";
5
6
  import { cx } from "../cx.js";
6
7
  import { Switch, cbox } from "./inputs.js";
7
8
  import { Badge, Button, IconButton, TintIcon } from "./foundation.js";
@@ -20,27 +21,35 @@ import type {
20
21
  } from "./types.js";
21
22
 
22
23
  /* ── Real estate ── */
23
- function listingFacts(x: Listing) {
24
+ type ListingStrings = Pick<UIStrings, "rooms" | "priceOnRequest" | "perMonth">;
25
+
26
+ function listingFacts(x: Listing, t: ListingStrings, { formatNumber }: Formatters) {
24
27
  return [
25
28
  x.area != null && [<Icon key="i" name="ruler" size={14} />, `${formatNumber(x.area)} m²`],
26
- x.rooms != null && [<Icon key="i" name="bed" size={14} />, `${formatNumber(x.rooms)} rooms`],
29
+ x.rooms != null && [
30
+ <Icon key="i" name="bed" size={14} />,
31
+ t.rooms(formatNumber(x.rooms), x.rooms),
32
+ ],
27
33
  x.place && [<Icon key="i" name="pin" size={14} />, x.place],
28
34
  ]
29
35
  .filter(Boolean)
30
36
  .map((f, i) => <span key={i}>{f}</span>);
31
37
  }
32
38
 
33
- function listingPrice(x: Listing) {
34
- if (x.price == null) return <b className="tf-listing-price">{"Price on request"}</b>;
39
+ function listingPrice(x: Listing, t: ListingStrings, { formatMoney }: Formatters) {
40
+ if (x.price == null) return <b className="tf-listing-price">{t.priceOnRequest}</b>;
35
41
  return (
36
42
  <b className="tf-listing-price">
37
43
  {formatMoney(x.price, x.currency)}
38
- {x.rent && <small> {"/ month"}</small>}
44
+ {x.rent && <small> {t.perMonth}</small>}
39
45
  </b>
40
46
  );
41
47
  }
42
48
 
43
49
  export function ListingCard(p: ListingCardProps) {
50
+ const t = useStrings(REAL_ESTATE_STRINGS);
51
+ const fm = useFormat();
52
+ const { formatMoney, formatNumber } = fm;
44
53
  const x = p.listing || p;
45
54
  return (
46
55
  <article
@@ -65,7 +74,7 @@ export function ListingCard(p: ListingCardProps) {
65
74
  <Icon name="image" size={24} />
66
75
  </div>
67
76
  )}
68
- {x.isNew && <span className="tf-listing-new">{"New"}</span>}
77
+ {x.isNew && <span className="tf-listing-new">{t.isNew}</span>}
69
78
  {(x.photos ?? 0) > 1 && (
70
79
  <span className="tf-listing-photos">
71
80
  <Icon name="image" size={12} />
@@ -84,14 +93,14 @@ export function ListingCard(p: ListingCardProps) {
84
93
  onChange: () => {
85
94
  p.onSelect?.(x);
86
95
  },
87
- label: "Select",
96
+ label: t.select,
88
97
  })}
89
98
  </label>
90
99
  )}
91
100
  </div>
92
101
  <div className="tf-listing-body">
93
102
  <div className="tf-listing-top">
94
- {listingPrice(x)}
103
+ {listingPrice(x, t, fm)}
95
104
  {x.pricePerSqm != null && (
96
105
  <span className="tf-listing-ppsqm">
97
106
  {formatMoney(x.pricePerSqm, x.currency) + " / m²"}
@@ -101,7 +110,7 @@ export function ListingCard(p: ListingCardProps) {
101
110
  <IconButton
102
111
  icon="heart"
103
112
  size="sm"
104
- label={x.wished ? "Remove from saved" : "Save"}
113
+ label={x.wished ? t.removeFromSaved : t.save}
105
114
  className={cx("tf-listing-wish", x.wished && "on")}
106
115
  onClick={(e) => {
107
116
  e.stopPropagation();
@@ -111,16 +120,16 @@ export function ListingCard(p: ListingCardProps) {
111
120
  )}
112
121
  </div>
113
122
  <b className="tf-listing-title">{x.title}</b>
114
- <div className="tf-listing-facts">{listingFacts(x)}</div>
123
+ <div className="tf-listing-facts">{listingFacts(x, t, fm)}</div>
115
124
  <div className="tf-listing-foot">
116
125
  {x.source && <span className="tf-listing-src">{x.source}</span>}
117
126
  {x.seller && (
118
127
  <Badge tone={x.seller === "private" ? "success" : "neutral"} dot={false}>
119
- {x.sellerLabel || (x.seller === "private" ? "Private seller" : "Agent")}
128
+ {x.sellerLabel || (x.seller === "private" ? t.privateSeller : t.agent)}
120
129
  </Badge>
121
130
  )}
122
131
  {x.phone && (
123
- <span className="tf-listing-flag" title="Phone number available">
132
+ <span className="tf-listing-flag" title={t.phoneAvailable}>
124
133
  <Icon name="phone" size={13} />
125
134
  </span>
126
135
  )}
@@ -143,7 +152,7 @@ export function ListingCard(p: ListingCardProps) {
143
152
  onChange={(s) => {
144
153
  p.onStatus && p.onStatus(x, s);
145
154
  }}
146
- label="Status"
155
+ label={t.status}
147
156
  />
148
157
  </span>
149
158
  )}
@@ -154,6 +163,8 @@ export function ListingCard(p: ListingCardProps) {
154
163
  }
155
164
 
156
165
  export function ResultsToolbar(p: ResultsToolbarProps) {
166
+ const t = useStrings(REAL_ESTATE_STRINGS);
167
+ const { formatNumber } = useFormat();
157
168
  const all = p.total > 0 && p.selected === p.total,
158
169
  some = (p.selected ?? 0) > 0 && !all;
159
170
  return (
@@ -166,7 +177,7 @@ export function ResultsToolbar(p: ResultsToolbarProps) {
166
177
  onChange: () => {
167
178
  p.onSelectAll?.(!all);
168
179
  },
169
- label: all ? "Deselect all" : "Select all",
180
+ label: all ? t.deselectAll : t.selectAll,
170
181
  })}
171
182
  </label>
172
183
  )}
@@ -178,10 +189,12 @@ export function ResultsToolbar(p: ResultsToolbarProps) {
178
189
  >
179
190
  <b>
180
191
  {(p.selected ?? 0) > 0
181
- ? `${formatNumber(p.selected ?? 0)} selected`
182
- : p.title || "Results"}
192
+ ? t.selectedCount(p.selected ?? 0, formatNumber(p.selected ?? 0))
193
+ : p.title || t.results}
183
194
  </b>
184
- <small>{`${formatNumber(p.total || 0)} found` + (p.meta ? " · " + p.meta : "")}</small>
195
+ <small>
196
+ {t.foundCount(p.total || 0, formatNumber(p.total || 0)) + (p.meta ? " · " + p.meta : "")}
197
+ </small>
185
198
  </div>
186
199
  {(p.selected ?? 0) > 0 &&
187
200
  (p.bulkActions || []).map((a, i) => (
@@ -193,12 +206,12 @@ export function ResultsToolbar(p: ResultsToolbarProps) {
193
206
  disabled={a.busy}
194
207
  onClick={a.onClick}
195
208
  >
196
- {a.busy ? a.busyLabel || "Working…" : a.label}
209
+ {a.busy ? a.busyLabel || t.workingEllipsis : a.label}
197
210
  </Button>
198
211
  ))}
199
212
  {p.sortOptions && (
200
213
  <label className="tf-results-sort">
201
- <span>{"Sort"}</span>
214
+ <span>{t.sort}</span>
202
215
  <select
203
216
  className="tf-input"
204
217
  value={p.sort}
@@ -219,11 +232,11 @@ export function ResultsToolbar(p: ResultsToolbarProps) {
219
232
  size="sm"
220
233
  value={p.view}
221
234
  onChange={p.onView}
222
- label="View"
235
+ label={t.view}
223
236
  options={p.views.map((v) => ({
224
237
  value: v,
225
238
  icon: v === "grid" ? "grid" : v === "map" ? "map" : "list",
226
- label: v === "grid" ? "Grid" : v === "map" ? "Map" : "List",
239
+ label: v === "grid" ? t.viewGrid : v === "map" ? t.viewMap : t.viewList,
227
240
  }))}
228
241
  />
229
242
  )}
@@ -232,6 +245,8 @@ export function ResultsToolbar(p: ResultsToolbarProps) {
232
245
  }
233
246
 
234
247
  export function SavedSearch(p: SavedSearchProps) {
248
+ const t = useStrings(REAL_ESTATE_STRINGS);
249
+ const { formatNumber } = useFormat();
235
250
  return (
236
251
  <div className={cx("tf-saved", p.enabled === false && "off")}>
237
252
  <div className="tf-saved-head">
@@ -245,14 +260,14 @@ export function SavedSearch(p: SavedSearchProps) {
245
260
  >
246
261
  <b>{p.name}</b>
247
262
  <small>
248
- {[p.lastRun && `Checked ${p.lastRun}`, p.frequency].filter(Boolean).join(" · ")}
263
+ {[p.lastRun && t.checkedAt(p.lastRun), p.frequency].filter(Boolean).join(" · ")}
249
264
  </small>
250
265
  </div>
251
266
  {(p.newCount ?? 0) > 0 && (
252
267
  <span className="tf-nav-count">{formatNumber(p.newCount ?? 0)}</span>
253
268
  )}
254
269
  {p.onToggle && (
255
- <Switch checked={p.enabled !== false} onChange={p.onToggle} label="Enabled" />
270
+ <Switch checked={p.enabled !== false} onChange={p.onToggle} label={t.enabled} />
256
271
  )}
257
272
  </div>
258
273
  <div className="tf-saved-crit">
@@ -271,12 +286,12 @@ export function SavedSearch(p: SavedSearchProps) {
271
286
  >
272
287
  {p.onEdit && (
273
288
  <Button size="sm" variant="ghost" icon="edit" onClick={p.onEdit}>
274
- {"Edit"}
289
+ {t.edit}
275
290
  </Button>
276
291
  )}
277
292
  {p.onOpen && (
278
293
  <Button size="sm" variant="soft" onClick={p.onOpen}>
279
- {p.openLabel || "Show results"}
294
+ {p.openLabel || t.showResults}
280
295
  </Button>
281
296
  )}
282
297
  </div>
@@ -286,6 +301,8 @@ export function SavedSearch(p: SavedSearchProps) {
286
301
  }
287
302
 
288
303
  export function ValuationCard(p: ValuationCardProps) {
304
+ const t = useStrings(REAL_ESTATE_STRINGS);
305
+ const { formatMoney, formatNumber } = useFormat();
289
306
  const lo = p.low,
290
307
  hi = p.high,
291
308
  v = p.value,
@@ -296,10 +313,10 @@ export function ValuationCard(p: ValuationCardProps) {
296
313
  conf == null
297
314
  ? null
298
315
  : conf >= 0.75
299
- ? ["ok", "High confidence"]
316
+ ? ["ok", t.highConfidence]
300
317
  : conf >= 0.5
301
- ? ["warn", "Medium confidence"]
302
- : ["danger", "Low confidence"];
318
+ ? ["warn", t.mediumConfidence]
319
+ : ["danger", t.lowConfidence];
303
320
  return (
304
321
  <div className="tf-valuation">
305
322
  <div className="tf-valuation-head">
@@ -309,7 +326,7 @@ export function ValuationCard(p: ValuationCardProps) {
309
326
  padding: 0,
310
327
  }}
311
328
  >
312
- {p.title || "Estimated value"}
329
+ {p.title || t.estimatedValue}
313
330
  </span>
314
331
  {cl && <StatusIndicator status={cl[0]} label={p.confidenceLabel || cl[1]} size="sm" />}
315
332
  </div>
@@ -322,7 +339,7 @@ export function ValuationCard(p: ValuationCardProps) {
322
339
  <div
323
340
  className="tf-valuation-range"
324
341
  role="img"
325
- aria-label={`Range ${formatMoney(lo, p.currency)} to ${formatMoney(hi, p.currency)}`}
342
+ aria-label={t.range(formatMoney(lo, p.currency), formatMoney(hi, p.currency))}
326
343
  >
327
344
  <div className="tf-valuation-bar">
328
345
  {p.asking != null && (
@@ -331,7 +348,7 @@ export function ValuationCard(p: ValuationCardProps) {
331
348
  style={{
332
349
  left: Math.max(0, Math.min(100, ((p.asking - lo) / span) * 100)) + "%",
333
350
  }}
334
- title="Asking price"
351
+ title={t.askingPrice}
335
352
  />
336
353
  )}
337
354
  <span
@@ -350,7 +367,9 @@ export function ValuationCard(p: ValuationCardProps) {
350
367
  <span
351
368
  className={cx("tf-valuation-diff", p.asking > hi ? "bad" : p.asking < lo ? "good" : "")}
352
369
  >
353
- {`Asking price ${formatMoney(p.asking, p.currency)}` +
370
+ {t.askingPrice +
371
+ " " +
372
+ formatMoney(p.asking, p.currency) +
354
373
  " · " +
355
374
  (p.asking > v ? "+" : "") +
356
375
  formatNumber((p.asking - v) / v, {
@@ -378,6 +397,8 @@ export function ValuationCard(p: ValuationCardProps) {
378
397
  const ENERGY = ["A+", "A", "B", "C", "D", "E", "F", "G", "H"];
379
398
 
380
399
  export function EnergyClass(p: EnergyClassProps) {
400
+ const t = useStrings(REAL_ESTATE_STRINGS);
401
+ const { formatNumber } = useFormat();
381
402
  const scale = p.scale || ENERGY,
382
403
  i = scale.indexOf(p.value);
383
404
  return (
@@ -385,8 +406,7 @@ export function EnergyClass(p: EnergyClassProps) {
385
406
  className="tf-energy"
386
407
  role="img"
387
408
  aria-label={
388
- `Energy class ${p.value}` +
389
- (p.kwh != null ? ", " + formatNumber(p.kwh) + " kWh/(m²·a)" : "")
409
+ t.energyClass(p.value) + (p.kwh != null ? ", " + formatNumber(p.kwh) + " kWh/(m²·a)" : "")
390
410
  }
391
411
  >
392
412
  <div
@@ -2,6 +2,8 @@
2
2
  import * as React from "react";
3
3
  import { formatNumber } from "../format.js";
4
4
  import { cx } from "../cx.js";
5
+ import { useStrings } from "../context.js";
6
+ import { RICH_TEXT_EDITOR_STRINGS } from "../strings.js";
5
7
  import { Icon } from "../Icon.js";
6
8
  import { FormField } from "./forms.js";
7
9
  import { Popover } from "./overlays.js";
@@ -72,6 +74,7 @@ function rteClean(html: string | undefined): string {
72
74
  }
73
75
 
74
76
  export function RichTextEditor(p: RichTextEditorProps) {
77
+ const strings = useStrings(RICH_TEXT_EDITOR_STRINGS);
75
78
  const ref = React.useRef<HTMLDivElement>(null),
76
79
  count = React.useState(0),
77
80
  active = React.useState<Record<string, boolean>>({}),
@@ -411,7 +414,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
411
414
  () => {
412
415
  mark("B", "STRONG");
413
416
  },
414
- "Bold",
417
+ strings.bold,
415
418
  "B",
416
419
  ],
417
420
  [
@@ -419,7 +422,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
419
422
  () => {
420
423
  mark("I", "EM");
421
424
  },
422
- "Italic",
425
+ strings.italic,
423
426
  "I",
424
427
  ],
425
428
  [
@@ -427,7 +430,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
427
430
  () => {
428
431
  mark("U");
429
432
  },
430
- "Underline",
433
+ strings.underline,
431
434
  "U",
432
435
  ],
433
436
  "-",
@@ -436,38 +439,38 @@ export function RichTextEditor(p: RichTextEditorProps) {
436
439
  () => {
437
440
  setBlock("H3");
438
441
  },
439
- "Heading",
442
+ strings.heading,
440
443
  ],
441
444
  [
442
445
  "quote",
443
446
  () => {
444
447
  setBlock("BLOCKQUOTE");
445
448
  },
446
- "Quote",
449
+ strings.quote,
447
450
  ],
448
451
  [
449
452
  "list",
450
453
  () => {
451
454
  setList("UL");
452
455
  },
453
- "Bulleted list",
456
+ strings.bulletedList,
454
457
  ],
455
458
  [
456
459
  "olist",
457
460
  () => {
458
461
  setList("OL");
459
462
  },
460
- "Numbered list",
463
+ strings.numberedList,
461
464
  ],
462
465
  "-",
463
- ["link", openLink, "Link", "K"],
466
+ ["link", openLink, strings.link, "K"],
464
467
  "-",
465
468
  [
466
469
  "undo",
467
470
  () => {
468
471
  restore(-1);
469
472
  },
470
- "Undo",
473
+ strings.undo,
471
474
  "Z",
472
475
  ],
473
476
  [
@@ -475,7 +478,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
475
478
  () => {
476
479
  restore(1);
477
480
  },
478
- "Redo",
481
+ strings.redo,
479
482
  ],
480
483
  ];
481
484
  const over = p.maxLength && count[0] > p.maxLength,
@@ -486,7 +489,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
486
489
  return (
487
490
  <FormField label={p.label} hint={p.hint} error={p.error}>
488
491
  <div className={cx("tf-rte", over && "invalid")}>
489
- <div className="tf-rte-bar" role="toolbar" aria-label="Formatting">
492
+ <div className="tf-rte-bar" role="toolbar" aria-label={strings.formatting}>
490
493
  {tools.map((t, i) => {
491
494
  if (t === "-") return <span key={i} className="tf-rte-sep" aria-hidden />;
492
495
  const btn = (
@@ -531,8 +534,8 @@ export function RichTextEditor(p: RichTextEditorProps) {
531
534
  onChange={(e) => {
532
535
  linkUrl[1](e.target.value);
533
536
  }}
534
- placeholder="Link address"
535
- aria-label="Link address"
537
+ placeholder={strings.linkAddress}
538
+ aria-label={strings.linkAddress}
536
539
  inputMode="url"
537
540
  />
538
541
  <div
@@ -550,11 +553,11 @@ export function RichTextEditor(p: RichTextEditorProps) {
550
553
  applyLink(true);
551
554
  }}
552
555
  >
553
- {"Remove link"}
556
+ {strings.removeLink}
554
557
  </Button>
555
558
  )}
556
559
  <Button size="sm" type="submit">
557
- {"Apply"}
560
+ {strings.apply}
558
561
  </Button>
559
562
  </div>
560
563
  </form>
@@ -577,7 +580,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
577
580
  aria-expanded={s.open}
578
581
  >
579
582
  {"{ }"}
580
- <span>{"Insert field"}</span>
583
+ <span>{strings.insertField}</span>
581
584
  </button>
582
585
  )}
583
586
  >
@@ -617,7 +620,7 @@ export function RichTextEditor(p: RichTextEditorProps) {
617
620
  contentEditable={!p.disabled}
618
621
  role="textbox"
619
622
  aria-multiline="true"
620
- aria-label={typeof p.label === "string" ? p.label : "Text"}
623
+ aria-label={typeof p.label === "string" ? p.label : strings.text}
621
624
  data-placeholder={p.placeholder || ""}
622
625
  suppressContentEditableWarning
623
626
  onInput={input}
@@ -5,6 +5,8 @@
5
5
  * picks, Escape closes. Inside a Form it submits under `name` and shows its message.
6
6
  */
7
7
  import * as React from "react";
8
+ import { useStrings } from "../context.js";
9
+ import { SELECT_MENU_STRINGS } from "../strings.js";
8
10
  import { cx } from "../cx.js";
9
11
  import { Icon } from "../Icon.js";
10
12
  import { useControlled, useId } from "../hooks.js";
@@ -16,6 +18,7 @@ const norm = (o: string | SelectMenuOption): SelectMenuOption =>
16
18
  typeof o === "string" ? { value: o, label: o } : o;
17
19
 
18
20
  export function SelectMenu(p: SelectMenuProps) {
21
+ const t = useStrings(SELECT_MENU_STRINGS);
19
22
  const uid = useId("sel");
20
23
  const id = p.id || uid;
21
24
  const fromForm = useFieldError(p.name);
@@ -121,7 +124,7 @@ export function SelectMenu(p: SelectMenuProps) {
121
124
  >
122
125
  {cur?.icon && <Icon name={cur.icon} size={15} />}
123
126
  <span className="tf-selectmenu-value">
124
- {cur ? cur.label : p.placeholder || "Select…"}
127
+ {cur ? cur.label : p.placeholder || t.selectPlaceholder}
125
128
  </span>
126
129
  <Icon name="chevronDown" size={16} className="tf-selectmenu-chev" />
127
130
  </button>
@@ -134,7 +137,7 @@ export function SelectMenu(p: SelectMenuProps) {
134
137
  role="listbox"
135
138
  tabIndex={-1}
136
139
  aria-labelledby={p.label ? `${id}-label` : undefined}
137
- aria-label={p.label ? undefined : p.placeholder || "Options"}
140
+ aria-label={p.label ? undefined : p.placeholder || t.options}
138
141
  aria-activedescendant={active >= 0 ? `${uid}-o${active}` : undefined}
139
142
  onKeyDown={(e) => {
140
143
  const keys: Record<string, () => void> = {