@groveback/ui 0.3.0 → 0.4.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.
package/dist/index.d.ts CHANGED
@@ -15,10 +15,12 @@ import { MarkdownField, renderMarkdown } from './MarkdownField';
15
15
  import { RelationSelect } from './RelationSelect';
16
16
  import { GroveRoutes, RouteIndex, matchRoute, resolveRoute } from './routes';
17
17
  import { SignIn } from './SignIn';
18
- import { GroveUiProvider, detectLocale, formatValue, resolveHref, useAction, useGroveUi, useMessages } from './runtime';
19
- export { Button, DataTable, Detail, Divider, Form, Grid, GroveRoutes, GroveUiProvider, Header, Heading, MarkdownField, renderMarkdown, RelationSelect, RouteIndex, SignIn, Stack, Text, detectLocale, formatValue, matchRoute, resolveHref, resolveRoute, useAction, useGroveUi, useMessages, };
18
+ import { uiMessagesFor } from './messages';
19
+ import { GroveUiProvider, detectLocale, formatValue, resolveHref, useAction, useGroveUi, useMessages, usePageTitle, useUiMessages, useUiText } from './runtime';
20
+ export { Button, DataTable, Detail, Divider, Form, Grid, GroveRoutes, GroveUiProvider, Header, Heading, MarkdownField, renderMarkdown, RelationSelect, RouteIndex, SignIn, Stack, Text, detectLocale, formatValue, matchRoute, resolveHref, resolveRoute, useAction, useGroveUi, useMessages, usePageTitle, uiMessagesFor, useUiMessages, useUiText, };
20
21
  export type { RelationSelectProps } from './RelationSelect';
21
22
  export type { GroveUiContext } from './runtime';
23
+ export type { UiMessageKey, UiMessages } from './messages';
22
24
  export type { GroveRoute } from './routes';
23
25
  export type { AuthLike, SignInProps } from './SignIn';
24
26
  export type { Action, AnyCollection, ButtonVariant, CollectionLike, Doc, FieldRef, Format, RowAction } from './types';
package/dist/index.js CHANGED
@@ -1,9 +1,257 @@
1
1
  // src/components.tsx
2
- import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
2
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
3
3
 
4
4
  // src/MarkdownField.tsx
5
5
  import { useState } from "react";
6
- import { jsx, jsxs } from "react/jsx-runtime";
6
+
7
+ // src/runtime.tsx
8
+ import { createContext, useContext, useCallback, useEffect, useMemo } from "react";
9
+
10
+ // src/messages.ts
11
+ var EN = {
12
+ "common.loading": "Loading…",
13
+ "common.notFound": "Not found.",
14
+ "common.home": "Home",
15
+ "common.pages": "Pages",
16
+ "common.yes": "Yes",
17
+ "common.no": "No",
18
+ "header.openMenu": "Open menu",
19
+ "header.closeMenu": "Close menu",
20
+ "table.empty": "Nothing here yet.",
21
+ "form.save": "Save",
22
+ "form.saving": "Saving…",
23
+ "form.created": "Saved.",
24
+ "form.updated": "Changes saved.",
25
+ "action.confirmDelete": "Delete this item? This cannot be undone.",
26
+ "relation.select": "Select…",
27
+ "relation.search": "Search…",
28
+ "relation.noMatches": "No matches.",
29
+ "relation.none": "None",
30
+ "signIn.title": "Sign in",
31
+ "signIn.register": "Create account",
32
+ "signIn.mfa": "Two-factor code",
33
+ "signIn.name": "Name",
34
+ "signIn.email": "Email",
35
+ "signIn.password": "Password",
36
+ "signIn.code": "Code from your authenticator app, or a backup code",
37
+ "signIn.mfaUnsupported": "This account requires a second factor, which this app does not support.",
38
+ "signIn.submit": "Sign in",
39
+ "signIn.submitRegister": "Create account",
40
+ "signIn.submitMfa": "Verify",
41
+ "signIn.wait": "Please wait…",
42
+ "signIn.toRegister": "Create an account",
43
+ "signIn.toSignIn": "Have an account? Sign in",
44
+ "signIn.registerUnavailable": "Registration is not available.",
45
+ "markdown.bold": "Bold",
46
+ "markdown.italic": "Italic",
47
+ "markdown.heading": "Heading",
48
+ "markdown.listItem": "List item",
49
+ "markdown.code": "Code",
50
+ "markdown.link": "Link",
51
+ "markdown.placeholder": "Write Markdown…",
52
+ "markdown.write": "Write",
53
+ "markdown.preview": "Preview",
54
+ "markdown.nothingToPreview": "Nothing to preview"
55
+ };
56
+ var ES = {
57
+ "common.loading": "Cargando…",
58
+ "common.notFound": "No encontrado.",
59
+ "common.home": "Inicio",
60
+ "common.pages": "Páginas",
61
+ "common.yes": "Sí",
62
+ "common.no": "No",
63
+ "header.openMenu": "Abrir menú",
64
+ "header.closeMenu": "Cerrar menú",
65
+ "table.empty": "Todavía no hay nada.",
66
+ "form.save": "Guardar",
67
+ "form.saving": "Guardando…",
68
+ "form.created": "Guardado.",
69
+ "form.updated": "Cambios guardados.",
70
+ "action.confirmDelete": "¿Eliminar este elemento? No se puede deshacer.",
71
+ "relation.select": "Selecciona…",
72
+ "relation.search": "Buscar…",
73
+ "relation.noMatches": "Sin resultados.",
74
+ "relation.none": "Ninguno",
75
+ "signIn.title": "Iniciar sesión",
76
+ "signIn.register": "Crear cuenta",
77
+ "signIn.mfa": "Código de dos factores",
78
+ "signIn.name": "Nombre",
79
+ "signIn.email": "Correo",
80
+ "signIn.password": "Contraseña",
81
+ "signIn.code": "Código de tu app de autenticación, o un código de respaldo",
82
+ "signIn.mfaUnsupported": "Esta cuenta requiere un segundo factor, que esta app no admite.",
83
+ "signIn.submit": "Iniciar sesión",
84
+ "signIn.submitRegister": "Crear cuenta",
85
+ "signIn.submitMfa": "Verificar",
86
+ "signIn.wait": "Un momento…",
87
+ "signIn.toRegister": "Crear una cuenta",
88
+ "signIn.toSignIn": "¿Ya tienes cuenta? Inicia sesión",
89
+ "signIn.registerUnavailable": "El registro no está disponible.",
90
+ "markdown.bold": "Negrita",
91
+ "markdown.italic": "Cursiva",
92
+ "markdown.heading": "Encabezado",
93
+ "markdown.listItem": "Elemento de lista",
94
+ "markdown.code": "Código",
95
+ "markdown.link": "Enlace",
96
+ "markdown.placeholder": "Escribe en Markdown…",
97
+ "markdown.write": "Escribir",
98
+ "markdown.preview": "Vista previa",
99
+ "markdown.nothingToPreview": "Nada que previsualizar"
100
+ };
101
+ var UI_LOCALES = { en: EN, es: ES };
102
+ function uiMessagesFor(locale) {
103
+ if (!locale)
104
+ return EN;
105
+ const exact = UI_LOCALES[locale];
106
+ if (exact)
107
+ return exact;
108
+ const lang = locale.split("-")[0]?.toLowerCase();
109
+ return (lang ? UI_LOCALES[lang] : undefined) ?? EN;
110
+ }
111
+
112
+ // src/runtime.tsx
113
+ import { jsx } from "react/jsx-runtime";
114
+ var Ctx = createContext(null);
115
+ function GroveUiProvider({ value, children }) {
116
+ return /* @__PURE__ */ jsx(Ctx.Provider, {
117
+ value,
118
+ children
119
+ });
120
+ }
121
+ var DEFAULT_CONTEXT = {
122
+ navigate: (href) => {
123
+ window.history.pushState({}, "", href);
124
+ window.dispatchEvent(new PopStateEvent("popstate"));
125
+ }
126
+ };
127
+ function useGroveUi() {
128
+ return useContext(Ctx) ?? DEFAULT_CONTEXT;
129
+ }
130
+ function uiText(ctx, key) {
131
+ return ctx.messages?.[key] ?? uiMessagesFor(ctx.locale)[key];
132
+ }
133
+ function useUiText() {
134
+ const ctx = useGroveUi();
135
+ return useCallback((key) => uiText(ctx, key), [ctx]);
136
+ }
137
+ function useUiMessages() {
138
+ const ctx = useGroveUi();
139
+ return useMemo(() => ({ ...uiMessagesFor(ctx.locale), ...ctx.messages }), [ctx]);
140
+ }
141
+ function resolveHref(href, doc) {
142
+ if (!doc)
143
+ return href;
144
+ return href.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
145
+ const value = doc[name];
146
+ return value === undefined || value === null ? whole : String(value);
147
+ });
148
+ }
149
+ function useAction() {
150
+ const ctx = useGroveUi();
151
+ return useCallback(async (action, opts = {}) => {
152
+ switch (action.kind) {
153
+ case "navigate":
154
+ ctx.navigate(resolveHref(action.href, opts.doc));
155
+ return;
156
+ case "delete": {
157
+ const id = opts.doc?.["id"];
158
+ if (typeof id !== "string" || !opts.collection)
159
+ return;
160
+ const ask = ctx.confirm ?? ((m) => window.confirm(m));
161
+ const message = action.confirm ?? uiText(ctx, "action.confirmDelete");
162
+ if (!await ask(message))
163
+ return;
164
+ await opts.collection.delete(id);
165
+ opts.onDone?.();
166
+ return;
167
+ }
168
+ case "endpoint": {
169
+ const href = resolveHref(action.path, opts.doc);
170
+ const method = action.method ?? "POST";
171
+ if (ctx.callEndpoint)
172
+ await ctx.callEndpoint(href, method);
173
+ else
174
+ await fetch(`${ctx.endpointBase ?? ""}${href}`, { method });
175
+ opts.onDone?.();
176
+ return;
177
+ }
178
+ }
179
+ }, [ctx]);
180
+ }
181
+ var MARKDOWN_CELL_MAX = 80;
182
+ function formatValue(value, format, opts = {}) {
183
+ if (value === null || value === undefined)
184
+ return "";
185
+ switch (format) {
186
+ case "boolean":
187
+ return value ? opts.messages?.["common.yes"] ?? "Yes" : opts.messages?.["common.no"] ?? "No";
188
+ case "currency": {
189
+ const n = Number(value);
190
+ if (!Number.isFinite(n))
191
+ return String(value);
192
+ return new Intl.NumberFormat(opts.locale, {
193
+ style: "currency",
194
+ currency: opts.currency ?? "USD"
195
+ }).format(n);
196
+ }
197
+ case "number": {
198
+ const n = Number(value);
199
+ return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
200
+ }
201
+ case "date": {
202
+ const text = String(value);
203
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
204
+ const d = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(text);
205
+ return Number.isNaN(d.getTime()) ? text : d.toLocaleDateString(opts.locale);
206
+ }
207
+ case "markdown": {
208
+ const text = String(value).replace(/\s+/g, " ").trim();
209
+ if (text.length <= MARKDOWN_CELL_MAX)
210
+ return text;
211
+ const cut = text.slice(0, MARKDOWN_CELL_MAX);
212
+ const lastSpace = cut.lastIndexOf(" ");
213
+ return `${(lastSpace > MARKDOWN_CELL_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
214
+ }
215
+ default:
216
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
217
+ }
218
+ }
219
+ function useMessages(messages, fallback) {
220
+ const { locale } = useGroveUi();
221
+ return useCallback((key) => {
222
+ const entry = messages[key];
223
+ if (!entry)
224
+ return key;
225
+ return (locale !== undefined ? entry[locale] : undefined) ?? entry[fallback] ?? key;
226
+ }, [messages, locale, fallback]);
227
+ }
228
+ function usePageTitle(title) {
229
+ useEffect(() => {
230
+ if (typeof document === "undefined" || !title)
231
+ return;
232
+ const previous = document.title;
233
+ document.title = title;
234
+ return () => {
235
+ document.title = previous;
236
+ };
237
+ }, [title]);
238
+ }
239
+ function detectLocale(locales, preferred = typeof navigator !== "undefined" ? navigator.languages : []) {
240
+ const first = locales[0] ?? "en";
241
+ for (const want of preferred) {
242
+ const exact = locales.find((l) => l.toLowerCase() === want.toLowerCase());
243
+ if (exact)
244
+ return exact;
245
+ const lang = want.split("-")[0]?.toLowerCase();
246
+ const loose = locales.find((l) => l.split("-")[0]?.toLowerCase() === lang);
247
+ if (loose)
248
+ return loose;
249
+ }
250
+ return first;
251
+ }
252
+
253
+ // src/MarkdownField.tsx
254
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
7
255
  function escapeHtml(s) {
8
256
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
257
  }
@@ -46,12 +294,12 @@ function renderMarkdown(src) {
46
294
  `);
47
295
  }
48
296
  var TOOLS = [
49
- { key: "b", label: "Bold", glyph: "B", wrap: { before: "**", after: "**", placeholder: "bold" } },
50
- { key: "i", label: "Italic", glyph: "I", wrap: { before: "*", after: "*", placeholder: "italic" } },
51
- { key: "h", label: "Heading", glyph: "H", wrap: { before: "## ", after: "", placeholder: "Heading" } },
52
- { key: "ul", label: "List item", glyph: "•", wrap: { before: "- ", after: "", placeholder: "item" } },
53
- { key: "code", label: "Code", glyph: "</>", wrap: { before: "`", after: "`", placeholder: "code" } },
54
- { key: "a", label: "Link", glyph: "\uD83D\uDD17", wrap: { before: "[", after: "](https://)", placeholder: "text" } }
297
+ { key: "b", label: "markdown.bold", glyph: "B", wrap: { before: "**", after: "**", placeholder: "bold" } },
298
+ { key: "i", label: "markdown.italic", glyph: "I", wrap: { before: "*", after: "*", placeholder: "italic" } },
299
+ { key: "h", label: "markdown.heading", glyph: "H", wrap: { before: "## ", after: "", placeholder: "Heading" } },
300
+ { key: "ul", label: "markdown.listItem", glyph: "•", wrap: { before: "- ", after: "", placeholder: "item" } },
301
+ { key: "code", label: "markdown.code", glyph: "</>", wrap: { before: "`", after: "`", placeholder: "code" } },
302
+ { key: "a", label: "markdown.link", glyph: "\uD83D\uDD17", wrap: { before: "[", after: "](https://)", placeholder: "text" } }
55
303
  ];
56
304
  var TOOL_BUTTON = "inline-flex h-7 min-w-7 items-center justify-center rounded px-1.5 text-xs text-neutral-600 " + "hover:bg-neutral-100 hover:text-neutral-900 disabled:opacity-40 " + "dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100";
57
305
  var PREVIEW_TYPOGRAPHY = "min-h-40 px-3 py-2 text-sm [&_a]:underline [&_code]:rounded [&_code]:bg-neutral-100 [&_code]:px-1 " + "[&_h1]:text-lg [&_h1]:font-semibold [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold " + "[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 dark:[&_code]:bg-neutral-800";
@@ -59,6 +307,7 @@ function MarkdownField({
59
307
  value,
60
308
  onChange
61
309
  }) {
310
+ const t = useUiText();
62
311
  const [mode, setMode] = useState("write");
63
312
  const [el, setEl] = useState(null);
64
313
  function apply(wrap) {
@@ -81,53 +330,53 @@ function MarkdownField({
81
330
  /* @__PURE__ */ jsxs("div", {
82
331
  className: "flex items-center gap-0.5 border-b border-neutral-200 px-1 py-1 dark:border-neutral-800",
83
332
  children: [
84
- TOOLS.map((t) => /* @__PURE__ */ jsx("button", {
333
+ TOOLS.map((tool) => /* @__PURE__ */ jsx2("button", {
85
334
  type: "button",
86
- title: t.label,
87
- "aria-label": t.label,
335
+ title: t(tool.label),
336
+ "aria-label": t(tool.label),
88
337
  disabled: mode === "preview",
89
- onClick: () => apply(t.wrap),
338
+ onClick: () => apply(tool.wrap),
90
339
  className: TOOL_BUTTON,
91
- children: t.glyph
92
- }, t.key)),
340
+ children: tool.glyph
341
+ }, tool.key)),
93
342
  /* @__PURE__ */ jsxs("div", {
94
343
  className: "ml-auto flex gap-0.5",
95
344
  children: [
96
- /* @__PURE__ */ jsx("button", {
345
+ /* @__PURE__ */ jsx2("button", {
97
346
  type: "button",
98
347
  onClick: () => setMode("write"),
99
348
  className: tab(mode === "write"),
100
- children: "Write"
349
+ children: t("markdown.write")
101
350
  }),
102
- /* @__PURE__ */ jsx("button", {
351
+ /* @__PURE__ */ jsx2("button", {
103
352
  type: "button",
104
353
  onClick: () => setMode("preview"),
105
354
  className: tab(mode === "preview"),
106
- children: "Preview"
355
+ children: t("markdown.preview")
107
356
  })
108
357
  ]
109
358
  })
110
359
  ]
111
360
  }),
112
- mode === "write" ? /* @__PURE__ */ jsx("textarea", {
361
+ mode === "write" ? /* @__PURE__ */ jsx2("textarea", {
113
362
  ref: setEl,
114
363
  rows: 8,
115
364
  value,
116
365
  spellCheck: false,
117
- placeholder: "Write Markdown…",
366
+ placeholder: t("markdown.placeholder"),
118
367
  onChange: (e) => onChange(e.target.value),
119
368
  className: "w-full resize-y rounded-b-md border-0 bg-transparent px-3 py-2 font-mono text-sm text-neutral-900 outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-500"
120
- }) : /* @__PURE__ */ jsx("div", {
369
+ }) : /* @__PURE__ */ jsx2("div", {
121
370
  className: PREVIEW_TYPOGRAPHY,
122
- dangerouslySetInnerHTML: { __html: renderMarkdown(value || "_Nothing to preview_") }
371
+ dangerouslySetInnerHTML: { __html: renderMarkdown(value || `_${t("markdown.nothingToPreview")}_`) }
123
372
  })
124
373
  ]
125
374
  });
126
375
  }
127
376
 
128
377
  // src/RelationSelect.tsx
129
- import { useEffect, useMemo, useRef, useState as useState2 } from "react";
130
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
378
+ import { useEffect as useEffect2, useMemo as useMemo2, useRef, useState as useState2 } from "react";
379
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
131
380
  var PAGE = 200;
132
381
  function labelOf(doc, field) {
133
382
  const raw = doc[field];
@@ -139,13 +388,14 @@ function labelOf(doc, field) {
139
388
  }
140
389
  function RelationSelect(props) {
141
390
  const { collection, displayField, value, onChange, placeholder, required } = props;
391
+ const t = useUiText();
142
392
  const [docs, setDocs] = useState2(null);
143
393
  const [error, setError] = useState2(null);
144
394
  const [query, setQuery] = useState2("");
145
395
  const [open, setOpen] = useState2(false);
146
396
  const [truncated, setTruncated] = useState2(false);
147
397
  const boxRef = useRef(null);
148
- useEffect(() => {
398
+ useEffect2(() => {
149
399
  let live = true;
150
400
  collection.find({ limit: PAGE }).then((found) => {
151
401
  if (!live)
@@ -160,7 +410,7 @@ function RelationSelect(props) {
160
410
  live = false;
161
411
  };
162
412
  }, [collection]);
163
- useEffect(() => {
413
+ useEffect2(() => {
164
414
  if (!open)
165
415
  return;
166
416
  const onDown = (e) => {
@@ -170,8 +420,8 @@ function RelationSelect(props) {
170
420
  document.addEventListener("mousedown", onDown);
171
421
  return () => document.removeEventListener("mousedown", onDown);
172
422
  }, [open]);
173
- const selected = useMemo(() => (docs ?? []).find((d) => d["id"] === value) ?? null, [docs, value]);
174
- const matches = useMemo(() => {
423
+ const selected = useMemo2(() => (docs ?? []).find((d) => d["id"] === value) ?? null, [docs, value]);
424
+ const matches = useMemo2(() => {
175
425
  const all = docs ?? [];
176
426
  const q = query.trim().toLowerCase();
177
427
  if (q === "")
@@ -179,12 +429,12 @@ function RelationSelect(props) {
179
429
  return all.filter((d) => labelOf(d, displayField).toLowerCase().includes(q));
180
430
  }, [docs, displayField, query]);
181
431
  if (error) {
182
- return /* @__PURE__ */ jsx2("p", {
432
+ return /* @__PURE__ */ jsx3("p", {
183
433
  className: "text-xs text-red-600",
184
434
  children: error
185
435
  });
186
436
  }
187
- const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? "Select…";
437
+ const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? t("relation.select");
188
438
  return /* @__PURE__ */ jsxs2("div", {
189
439
  ref: boxRef,
190
440
  className: "relative",
@@ -194,11 +444,11 @@ function RelationSelect(props) {
194
444
  onClick: () => setOpen((v) => !v),
195
445
  className: "flex w-full items-center justify-between rounded-md border border-neutral-300 px-3 py-1.5 text-left text-sm dark:border-neutral-700 dark:bg-neutral-950",
196
446
  children: [
197
- /* @__PURE__ */ jsx2("span", {
447
+ /* @__PURE__ */ jsx3("span", {
198
448
  className: selected || value ? "" : "text-neutral-400",
199
- children: docs === null ? "Loading…" : buttonLabel
449
+ children: docs === null ? t("common.loading") : buttonLabel
200
450
  }),
201
- /* @__PURE__ */ jsx2("span", {
451
+ /* @__PURE__ */ jsx3("span", {
202
452
  "aria-hidden": true,
203
453
  className: "ml-2 text-neutral-400",
204
454
  children: "▾"
@@ -208,18 +458,18 @@ function RelationSelect(props) {
208
458
  open ? /* @__PURE__ */ jsxs2("div", {
209
459
  className: "absolute z-20 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg dark:border-neutral-800 dark:bg-neutral-950",
210
460
  children: [
211
- /* @__PURE__ */ jsx2("input", {
461
+ /* @__PURE__ */ jsx3("input", {
212
462
  autoFocus: true,
213
463
  value: query,
214
464
  onChange: (e) => setQuery(e.target.value),
215
- placeholder: "Search…",
465
+ placeholder: t("relation.search"),
216
466
  className: "w-full rounded-t-md border-b border-neutral-200 px-3 py-1.5 text-sm outline-none dark:border-neutral-800 dark:bg-neutral-950"
217
467
  }),
218
468
  /* @__PURE__ */ jsxs2("ul", {
219
469
  className: "max-h-56 overflow-auto py-1",
220
470
  children: [
221
- !required ? /* @__PURE__ */ jsx2("li", {
222
- children: /* @__PURE__ */ jsx2("button", {
471
+ !required ? /* @__PURE__ */ jsx3("li", {
472
+ children: /* @__PURE__ */ jsx3("button", {
223
473
  type: "button",
224
474
  onClick: () => {
225
475
  onChange("");
@@ -227,16 +477,16 @@ function RelationSelect(props) {
227
477
  setQuery("");
228
478
  },
229
479
  className: "w-full px-3 py-1.5 text-left text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-900",
230
- children: "None"
480
+ children: t("relation.none")
231
481
  })
232
482
  }) : null,
233
- matches.length === 0 ? /* @__PURE__ */ jsx2("li", {
483
+ matches.length === 0 ? /* @__PURE__ */ jsx3("li", {
234
484
  className: "px-3 py-2 text-xs text-neutral-500",
235
- children: "No matches."
485
+ children: t("relation.noMatches")
236
486
  }) : matches.map((doc) => {
237
487
  const id = typeof doc["id"] === "string" ? doc["id"] : "";
238
- return /* @__PURE__ */ jsx2("li", {
239
- children: /* @__PURE__ */ jsx2("button", {
488
+ return /* @__PURE__ */ jsx3("li", {
489
+ children: /* @__PURE__ */ jsx3("button", {
240
490
  type: "button",
241
491
  onClick: () => {
242
492
  onChange(id);
@@ -264,126 +514,6 @@ function RelationSelect(props) {
264
514
  });
265
515
  }
266
516
 
267
- // src/runtime.tsx
268
- import { createContext, useContext, useCallback } from "react";
269
- import { jsx as jsx3 } from "react/jsx-runtime";
270
- var Ctx = createContext(null);
271
- function GroveUiProvider({ value, children }) {
272
- return /* @__PURE__ */ jsx3(Ctx.Provider, {
273
- value,
274
- children
275
- });
276
- }
277
- var DEFAULT_CONTEXT = {
278
- navigate: (href) => {
279
- window.history.pushState({}, "", href);
280
- window.dispatchEvent(new PopStateEvent("popstate"));
281
- }
282
- };
283
- function useGroveUi() {
284
- return useContext(Ctx) ?? DEFAULT_CONTEXT;
285
- }
286
- function resolveHref(href, doc) {
287
- if (!doc)
288
- return href;
289
- return href.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
290
- const value = doc[name];
291
- return value === undefined || value === null ? whole : String(value);
292
- });
293
- }
294
- function useAction() {
295
- const ctx = useGroveUi();
296
- return useCallback(async (action, opts = {}) => {
297
- switch (action.kind) {
298
- case "navigate":
299
- ctx.navigate(resolveHref(action.href, opts.doc));
300
- return;
301
- case "delete": {
302
- const id = opts.doc?.["id"];
303
- if (typeof id !== "string" || !opts.collection)
304
- return;
305
- const ask = ctx.confirm ?? ((m) => window.confirm(m));
306
- const message = action.confirm ?? "Delete this item? This cannot be undone.";
307
- if (!await ask(message))
308
- return;
309
- await opts.collection.delete(id);
310
- opts.onDone?.();
311
- return;
312
- }
313
- case "endpoint": {
314
- const href = resolveHref(action.path, opts.doc);
315
- const method = action.method ?? "POST";
316
- if (ctx.callEndpoint)
317
- await ctx.callEndpoint(href, method);
318
- else
319
- await fetch(`${ctx.endpointBase ?? ""}${href}`, { method });
320
- opts.onDone?.();
321
- return;
322
- }
323
- }
324
- }, [ctx]);
325
- }
326
- var MARKDOWN_CELL_MAX = 80;
327
- function formatValue(value, format, opts = {}) {
328
- if (value === null || value === undefined)
329
- return "";
330
- switch (format) {
331
- case "boolean":
332
- return value ? "Yes" : "No";
333
- case "currency": {
334
- const n = Number(value);
335
- if (!Number.isFinite(n))
336
- return String(value);
337
- return new Intl.NumberFormat(opts.locale, {
338
- style: "currency",
339
- currency: opts.currency ?? "USD"
340
- }).format(n);
341
- }
342
- case "number": {
343
- const n = Number(value);
344
- return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
345
- }
346
- case "date": {
347
- const text = String(value);
348
- const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
349
- const d = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(text);
350
- return Number.isNaN(d.getTime()) ? text : d.toLocaleDateString(opts.locale);
351
- }
352
- case "markdown": {
353
- const text = String(value).replace(/\s+/g, " ").trim();
354
- if (text.length <= MARKDOWN_CELL_MAX)
355
- return text;
356
- const cut = text.slice(0, MARKDOWN_CELL_MAX);
357
- const lastSpace = cut.lastIndexOf(" ");
358
- return `${(lastSpace > MARKDOWN_CELL_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
359
- }
360
- default:
361
- return typeof value === "object" ? JSON.stringify(value) : String(value);
362
- }
363
- }
364
- function useMessages(messages, fallback) {
365
- const { locale } = useGroveUi();
366
- return useCallback((key) => {
367
- const entry = messages[key];
368
- if (!entry)
369
- return key;
370
- return (locale !== undefined ? entry[locale] : undefined) ?? entry[fallback] ?? key;
371
- }, [messages, locale, fallback]);
372
- }
373
- function detectLocale(locales, preferred = typeof navigator !== "undefined" ? navigator.languages : []) {
374
- const first = locales[0] ?? "en";
375
- for (const want of preferred) {
376
- const exact = locales.find((l) => l.toLowerCase() === want.toLowerCase());
377
- if (exact)
378
- return exact;
379
- const lang = want.split("-")[0]?.toLowerCase();
380
- const loose = locales.find((l) => l.split("-")[0]?.toLowerCase() === lang);
381
- if (loose)
382
- return loose;
383
- }
384
- return first;
385
- }
386
-
387
517
  // src/styles.ts
388
518
  function cx(...parts) {
389
519
  return parts.filter(Boolean).join(" ");
@@ -447,6 +577,7 @@ function Text({ muted, children }) {
447
577
  function Header(props) {
448
578
  const { brand, brandHref = "/", links, actions, sticky } = props;
449
579
  const dispatch = useAction();
580
+ const t = useUiText();
450
581
  const [open, setOpen] = useState3(false);
451
582
  const go = useCallback2((href) => (e) => {
452
583
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
@@ -490,7 +621,7 @@ function Header(props) {
490
621
  hasNav ? /* @__PURE__ */ jsx4("button", {
491
622
  type: "button",
492
623
  "data-grove-nav-toggle": true,
493
- "aria-label": open ? "Close menu" : "Open menu",
624
+ "aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
494
625
  "aria-expanded": open,
495
626
  onClick: () => setOpen((v) => !v),
496
627
  className: "ml-auto inline-flex items-center justify-center rounded-md p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 md:hidden dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100",
@@ -595,7 +726,7 @@ function useAsync(load, deps) {
595
726
  const [error, setError] = useState3(null);
596
727
  const [loading, setLoading] = useState3(true);
597
728
  const [nonce, setNonce] = useState3(0);
598
- useEffect2(() => {
729
+ useEffect3(() => {
599
730
  let live = true;
600
731
  setLoading(true);
601
732
  setError(null);
@@ -626,7 +757,7 @@ function useRelations(fields, docs, resolveCollection) {
626
757
  const [map, setMap] = useState3({});
627
758
  const relations = fields.filter((f) => f.display);
628
759
  const key = JSON.stringify(relations.map((f) => f.field)) + (docs?.length ?? 0);
629
- useEffect2(() => {
760
+ useEffect3(() => {
630
761
  if (!docs || relations.length === 0 || !resolveCollection)
631
762
  return;
632
763
  let live = true;
@@ -650,25 +781,26 @@ function useRelations(fields, docs, resolveCollection) {
650
781
  }, [key, resolveCollection]);
651
782
  return map;
652
783
  }
653
- function cellValue(doc, field, relations, locale, currency) {
784
+ function cellValue(doc, field, relations, locale, currency, messages) {
654
785
  const raw = doc[field.field];
655
786
  if (field.display) {
656
787
  const id = typeof raw === "string" ? raw : "";
657
788
  const referenced = relations[field.field]?.[id];
658
789
  if (referenced)
659
- return formatValue(referenced[field.display.field], "text", { locale, currency });
790
+ return formatValue(referenced[field.display.field], "text", { locale, currency, messages });
660
791
  }
661
- return formatValue(raw, field.format, { locale, currency });
792
+ return formatValue(raw, field.format, { locale, currency, messages });
662
793
  }
663
794
  function DataTable(props) {
664
795
  const { collection, options, columns, rowHref, rowActions, emptyText, resolveRelation } = props;
665
796
  const ctx = useGroveUi();
797
+ const messages = useUiMessages();
666
798
  const { data, error, loading, reload } = useAsync(() => collection.find(options), [JSON.stringify(options)]);
667
799
  const relations = useRelations(columns, data, resolveRelation);
668
800
  if (loading)
669
801
  return /* @__PURE__ */ jsx4(Message, {
670
802
  tone: "muted",
671
- children: "Loading…"
803
+ children: messages["common.loading"]
672
804
  });
673
805
  if (error)
674
806
  return /* @__PURE__ */ jsx4(Message, {
@@ -678,7 +810,7 @@ function DataTable(props) {
678
810
  if (!data || data.length === 0)
679
811
  return /* @__PURE__ */ jsx4(Message, {
680
812
  tone: "muted",
681
- children: emptyText ?? "Nothing here yet."
813
+ children: emptyText ?? messages["table.empty"]
682
814
  });
683
815
  return /* @__PURE__ */ jsx4("div", {
684
816
  className: "w-full overflow-x-auto rounded-md border border-neutral-200 dark:border-neutral-800",
@@ -706,7 +838,7 @@ function DataTable(props) {
706
838
  children: [
707
839
  columns.map((c) => /* @__PURE__ */ jsx4("td", {
708
840
  className: "px-3 py-2",
709
- children: cellValue(doc, c, relations, ctx.locale, ctx.currency)
841
+ children: cellValue(doc, c, relations, ctx.locale, ctx.currency, messages)
710
842
  }, c.field)),
711
843
  rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("td", {
712
844
  className: "px-3 py-2",
@@ -733,12 +865,13 @@ function DataTable(props) {
733
865
  function Detail(props) {
734
866
  const { collection, id, fields, resolveRelation } = props;
735
867
  const ctx = useGroveUi();
868
+ const messages = useUiMessages();
736
869
  const { data, error, loading } = useAsync(() => collection.get(id), [id]);
737
870
  const relations = useRelations(fields, data ? [data] : null, resolveRelation);
738
871
  if (loading)
739
872
  return /* @__PURE__ */ jsx4(Message, {
740
873
  tone: "muted",
741
- children: "Loading…"
874
+ children: messages["common.loading"]
742
875
  });
743
876
  if (error)
744
877
  return /* @__PURE__ */ jsx4(Message, {
@@ -748,7 +881,7 @@ function Detail(props) {
748
881
  if (!data)
749
882
  return /* @__PURE__ */ jsx4(Message, {
750
883
  tone: "muted",
751
- children: "Not found."
884
+ children: messages["common.notFound"]
752
885
  });
753
886
  return /* @__PURE__ */ jsx4("dl", {
754
887
  className: "divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800",
@@ -764,7 +897,7 @@ function Detail(props) {
764
897
  children: String(data[f.field] ?? "")
765
898
  }) : /* @__PURE__ */ jsx4("dd", {
766
899
  className: "col-span-2 text-sm",
767
- children: cellValue(data, f, relations, ctx.locale, ctx.currency)
900
+ children: cellValue(data, f, relations, ctx.locale, ctx.currency, messages)
768
901
  })
769
902
  ]
770
903
  }, f.field))
@@ -795,13 +928,14 @@ function coerce(raw, format) {
795
928
  function Form(props) {
796
929
  const { collection, mode, id, fields, submitLabel, onSuccess, resolveRelation } = props;
797
930
  const dispatch = useAction();
931
+ const t = useUiText();
798
932
  const [values, setValues] = useState3({});
799
933
  const [error, setError] = useState3(null);
800
934
  const [busy, setBusy] = useState3(false);
801
935
  const [done, setDone] = useState3(false);
802
936
  const submitting = useRef2(false);
803
937
  const [loading, setLoading] = useState3(mode === "update");
804
- useEffect2(() => {
938
+ useEffect3(() => {
805
939
  if (mode !== "update" || !id)
806
940
  return;
807
941
  let live = true;
@@ -865,7 +999,7 @@ function Form(props) {
865
999
  if (loading)
866
1000
  return /* @__PURE__ */ jsx4(Message, {
867
1001
  tone: "muted",
868
- children: "Loading…"
1002
+ children: t("common.loading")
869
1003
  });
870
1004
  return /* @__PURE__ */ jsxs3("form", {
871
1005
  onSubmit: submit,
@@ -919,14 +1053,14 @@ function Form(props) {
919
1053
  }) : null,
920
1054
  done && !error ? /* @__PURE__ */ jsx4(Message, {
921
1055
  tone: "success",
922
- children: mode === "create" ? "Saved." : "Changes saved."
1056
+ children: t(mode === "create" ? "form.created" : "form.updated")
923
1057
  }) : null,
924
1058
  /* @__PURE__ */ jsx4("div", {
925
1059
  children: /* @__PURE__ */ jsx4("button", {
926
1060
  type: "submit",
927
1061
  disabled: busy,
928
1062
  className: cx(BUTTON_BASE, VARIANT.default),
929
- children: busy ? "Saving…" : submitLabel ?? "Save"
1063
+ children: busy ? t("form.saving") : submitLabel ?? t("form.save")
930
1064
  })
931
1065
  })
932
1066
  ]
@@ -934,7 +1068,7 @@ function Form(props) {
934
1068
  }
935
1069
 
936
1070
  // src/routes.tsx
937
- import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo2, useState as useState4 } from "react";
1071
+ import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo3, useState as useState4 } from "react";
938
1072
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
939
1073
  function matchRoute(path, pattern) {
940
1074
  const clean = (path.split(/[?#]/)[0] ?? "").replace(/\/+$/, "") || "/";
@@ -971,7 +1105,7 @@ function resolveRoute(routes, path) {
971
1105
  function GroveRoutes(props) {
972
1106
  const { routes, context, fallback, children, gate } = props;
973
1107
  const [path, setPath] = useState4(() => window.location.pathname);
974
- useEffect3(() => {
1108
+ useEffect4(() => {
975
1109
  const onPop = () => setPath(window.location.pathname);
976
1110
  window.addEventListener("popstate", onPop);
977
1111
  return () => window.removeEventListener("popstate", onPop);
@@ -980,7 +1114,7 @@ function GroveRoutes(props) {
980
1114
  window.history.pushState({}, "", href);
981
1115
  setPath(href.split(/[?#]/)[0] ?? href);
982
1116
  }, []);
983
- const value = useMemo2(() => ({ ...context, navigate }), [context, navigate]);
1117
+ const value = useMemo3(() => ({ ...context, navigate }), [context, navigate]);
984
1118
  const hit = resolveRoute(routes, path);
985
1119
  const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
986
1120
  const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
@@ -997,12 +1131,13 @@ function GroveRoutes(props) {
997
1131
  }
998
1132
  function NotFound() {
999
1133
  const { navigate } = useGroveUi();
1134
+ const t = useUiText();
1000
1135
  return /* @__PURE__ */ jsxs4("div", {
1001
1136
  className: "flex flex-col gap-2 p-6",
1002
1137
  children: [
1003
1138
  /* @__PURE__ */ jsx5("p", {
1004
1139
  className: "text-sm text-neutral-500",
1005
- children: "Not found."
1140
+ children: t("common.notFound")
1006
1141
  }),
1007
1142
  /* @__PURE__ */ jsx5("a", {
1008
1143
  href: "/",
@@ -1011,21 +1146,22 @@ function NotFound() {
1011
1146
  e.preventDefault();
1012
1147
  navigate("/");
1013
1148
  },
1014
- children: "Home"
1149
+ children: t("common.home")
1015
1150
  })
1016
1151
  ]
1017
1152
  });
1018
1153
  }
1019
1154
  function RouteIndex({ routes }) {
1020
1155
  const { navigate } = useGroveUi();
1156
+ const t = useUiText();
1021
1157
  const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
1022
1158
  return /* @__PURE__ */ jsxs4("nav", {
1023
1159
  className: "flex flex-col gap-3 p-6",
1024
- "aria-label": "Pages",
1160
+ "aria-label": t("common.pages"),
1025
1161
  children: [
1026
1162
  /* @__PURE__ */ jsx5("h1", {
1027
1163
  className: "text-2xl font-semibold",
1028
- children: "Pages"
1164
+ children: t("common.pages")
1029
1165
  }),
1030
1166
  /* @__PURE__ */ jsx5("ul", {
1031
1167
  className: "flex flex-col gap-1",
@@ -1056,6 +1192,7 @@ import { useCallback as useCallback4, useRef as useRef3, useState as useState5 }
1056
1192
  import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
1057
1193
  function SignIn(props) {
1058
1194
  const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
1195
+ const t = useUiText();
1059
1196
  const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
1060
1197
  const [mode, setMode] = useState5("login");
1061
1198
  const [email, setEmail] = useState5("");
@@ -1079,13 +1216,13 @@ function SignIn(props) {
1079
1216
  try {
1080
1217
  if (mode === "mfa") {
1081
1218
  if (!auth.verifyMfa)
1082
- throw new Error("This account requires a second factor, which this app does not support.");
1219
+ throw new Error(t("signIn.mfaUnsupported"));
1083
1220
  finish(await auth.verifyMfa(code));
1084
1221
  return;
1085
1222
  }
1086
1223
  if (mode === "register") {
1087
1224
  if (!auth.register)
1088
- throw new Error("Registration is not available.");
1225
+ throw new Error(t("signIn.registerUnavailable"));
1089
1226
  await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
1090
1227
  }
1091
1228
  const user = await auth.login(email, password);
@@ -1100,12 +1237,12 @@ function SignIn(props) {
1100
1237
  submitting.current = false;
1101
1238
  setBusy(false);
1102
1239
  }
1103
- }, [auth, code, email, finish, mode, name, password]);
1240
+ }, [auth, code, email, finish, mode, name, password, t]);
1104
1241
  if (signedIn)
1105
1242
  return /* @__PURE__ */ jsx6(Fragment2, {
1106
1243
  children
1107
1244
  });
1108
- const heading = mode === "register" ? "Create account" : mode === "mfa" ? "Two-factor code" : title ?? "Sign in";
1245
+ const heading = mode === "register" ? t("signIn.register") : mode === "mfa" ? t("signIn.mfa") : title ?? t("signIn.title");
1109
1246
  const label = "text-sm text-neutral-600 dark:text-neutral-400";
1110
1247
  return /* @__PURE__ */ jsx6("div", {
1111
1248
  className: "flex min-h-screen items-center justify-center p-6",
@@ -1123,7 +1260,7 @@ function SignIn(props) {
1123
1260
  children: [
1124
1261
  /* @__PURE__ */ jsx6("span", {
1125
1262
  className: label,
1126
- children: "Code from your authenticator app, or a backup code"
1263
+ children: t("signIn.code")
1127
1264
  }),
1128
1265
  /* @__PURE__ */ jsx6("input", {
1129
1266
  className: INPUT_CLASS,
@@ -1142,7 +1279,7 @@ function SignIn(props) {
1142
1279
  children: [
1143
1280
  /* @__PURE__ */ jsx6("span", {
1144
1281
  className: label,
1145
- children: "Name"
1282
+ children: t("signIn.name")
1146
1283
  }),
1147
1284
  /* @__PURE__ */ jsx6("input", {
1148
1285
  className: INPUT_CLASS,
@@ -1157,7 +1294,7 @@ function SignIn(props) {
1157
1294
  children: [
1158
1295
  /* @__PURE__ */ jsx6("span", {
1159
1296
  className: label,
1160
- children: "Email"
1297
+ children: t("signIn.email")
1161
1298
  }),
1162
1299
  /* @__PURE__ */ jsx6("input", {
1163
1300
  className: INPUT_CLASS,
@@ -1174,7 +1311,7 @@ function SignIn(props) {
1174
1311
  children: [
1175
1312
  /* @__PURE__ */ jsx6("span", {
1176
1313
  className: label,
1177
- children: "Password"
1314
+ children: t("signIn.password")
1178
1315
  }),
1179
1316
  /* @__PURE__ */ jsx6("input", {
1180
1317
  className: INPUT_CLASS,
@@ -1200,7 +1337,7 @@ function SignIn(props) {
1200
1337
  type: "submit",
1201
1338
  disabled: busy,
1202
1339
  className: cx(BUTTON_BASE, VARIANT.default),
1203
- children: busy ? "Please wait" : mode === "register" ? "Create account" : mode === "mfa" ? "Verify" : "Sign in"
1340
+ children: busy ? t("signIn.wait") : mode === "register" ? t("signIn.submitRegister") : mode === "mfa" ? t("signIn.submitMfa") : t("signIn.submit")
1204
1341
  }),
1205
1342
  allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
1206
1343
  type: "button",
@@ -1209,7 +1346,7 @@ function SignIn(props) {
1209
1346
  setError(null);
1210
1347
  setMode(mode === "register" ? "login" : "register");
1211
1348
  },
1212
- children: mode === "register" ? "Have an account? Sign in" : "Create an account"
1349
+ children: t(mode === "register" ? "signIn.toSignIn" : "signIn.toRegister")
1213
1350
  }) : null
1214
1351
  ]
1215
1352
  })
@@ -1240,7 +1377,11 @@ export {
1240
1377
  renderMarkdown,
1241
1378
  resolveHref,
1242
1379
  resolveRoute,
1380
+ uiMessagesFor,
1243
1381
  useAction,
1244
1382
  useGroveUi,
1245
- useMessages
1383
+ useMessages,
1384
+ usePageTitle,
1385
+ useUiMessages,
1386
+ useUiText
1246
1387
  };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The primitives' OWN strings — the chrome a generated app never authored.
3
+ *
4
+ * A screen's text is translated by the compiler: it ships a `MESSAGES` catalogue and looks it
5
+ * up with `useMessages`. Nothing did that for the words the primitives supply themselves —
6
+ * "Loading…", "Save", "Sign in", the empty-table line, the sign-in form's labels — so a
7
+ * Spanish app rendered Spanish headings above English chrome. These are that missing half.
8
+ *
9
+ * `en` is the complete catalogue and the fallback; every other locale may be partial, since a
10
+ * missing key falls back to English rather than showing a raw key. Locale matching is the same
11
+ * rule `detectLocale` uses for the document's own locales: exact first, then by language, so a
12
+ * viewer on `es-MX` gets the `es` chrome.
13
+ *
14
+ * An app that wants different wording — or a language not shipped here — passes `messages`
15
+ * on the provider context; those entries win over everything below. That is the extension
16
+ * point, and it is why this catalogue can stay small rather than trying to cover every
17
+ * language a generated app might be published in.
18
+ */
19
+ /** Every string the primitives render. Keys are dotted by the primitive that owns them. */
20
+ export interface UiMessages {
21
+ 'common.loading': string;
22
+ 'common.notFound': string;
23
+ 'common.home': string;
24
+ 'common.pages': string;
25
+ 'common.yes': string;
26
+ 'common.no': string;
27
+ 'header.openMenu': string;
28
+ 'header.closeMenu': string;
29
+ 'table.empty': string;
30
+ 'form.save': string;
31
+ 'form.saving': string;
32
+ 'form.created': string;
33
+ 'form.updated': string;
34
+ 'action.confirmDelete': string;
35
+ 'relation.select': string;
36
+ 'relation.search': string;
37
+ 'relation.noMatches': string;
38
+ 'relation.none': string;
39
+ 'signIn.title': string;
40
+ 'signIn.register': string;
41
+ 'signIn.mfa': string;
42
+ 'signIn.name': string;
43
+ 'signIn.email': string;
44
+ 'signIn.password': string;
45
+ 'signIn.code': string;
46
+ 'signIn.mfaUnsupported': string;
47
+ 'signIn.submit': string;
48
+ 'signIn.submitRegister': string;
49
+ 'signIn.submitMfa': string;
50
+ 'signIn.wait': string;
51
+ 'signIn.toRegister': string;
52
+ 'signIn.toSignIn': string;
53
+ 'signIn.registerUnavailable': string;
54
+ 'markdown.bold': string;
55
+ 'markdown.italic': string;
56
+ 'markdown.heading': string;
57
+ 'markdown.listItem': string;
58
+ 'markdown.code': string;
59
+ 'markdown.link': string;
60
+ 'markdown.placeholder': string;
61
+ 'markdown.write': string;
62
+ 'markdown.preview': string;
63
+ 'markdown.nothingToPreview': string;
64
+ }
65
+ /** A message key — what `useUiText()` accepts. */
66
+ export type UiMessageKey = keyof UiMessages;
67
+ /** The locales this package ships chrome for. Anything else falls back to English. */
68
+ export declare const UI_LOCALES: Record<string, UiMessages>;
69
+ /**
70
+ * The built-in catalogue for a locale: exact match, then by language (`es-MX` → `es`), then
71
+ * English. Exported for the tests and for an app that wants to read the shipped wording
72
+ * before overriding part of it.
73
+ */
74
+ export declare function uiMessagesFor(locale: string | undefined): UiMessages;
package/dist/runtime.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * under any of them (Next, React Router, plain history). The default uses the History API.
6
6
  */
7
7
  import type { ReactNode } from 'react';
8
+ import { type UiMessageKey, type UiMessages } from './messages';
8
9
  import type { Action, CollectionLike, Doc, Format } from './types';
9
10
  export interface GroveUiContext {
10
11
  /** Navigate to a route. */
@@ -21,12 +22,35 @@ export interface GroveUiContext {
21
22
  confirm?: (message: string) => boolean | Promise<boolean>;
22
23
  locale?: string;
23
24
  currency?: string;
25
+ /**
26
+ * Overrides for the primitives' OWN strings (`messages.ts`) — the chrome an app never
27
+ * authored. Partial: whatever is not given falls back to the shipped catalogue for the
28
+ * active locale, and then to English. This is how an app reaches a language the package
29
+ * does not ship, or changes wording it does ship.
30
+ */
31
+ messages?: Partial<UiMessages>;
24
32
  }
25
33
  export declare function GroveUiProvider({ value, children }: {
26
34
  value: GroveUiContext;
27
35
  children: ReactNode;
28
36
  }): import("react").JSX.Element;
29
37
  export declare function useGroveUi(): GroveUiContext;
38
+ /**
39
+ * One chrome string, resolved against a context: the app's own override first, then the
40
+ * catalogue shipped for the active locale, then English. Takes the context rather than
41
+ * reading it, so the non-hook call sites (`useAction`'s confirm) share exactly this rule.
42
+ */
43
+ export declare function uiText(ctx: GroveUiContext, key: UiMessageKey): string;
44
+ /**
45
+ * The primitives' translator — `t` for the words the package supplies itself, as opposed to
46
+ * `useMessages`, which is a screen's translator for the words its author wrote.
47
+ *
48
+ * Returned as a function rather than the whole catalogue so a primitive reads one key at a
49
+ * time and a missing key is a type error, not a blank on the page.
50
+ */
51
+ export declare function useUiText(): (key: UiMessageKey) => string;
52
+ /** The chrome catalogue in force — for the few places that need several keys at once. */
53
+ export declare function useUiMessages(): UiMessages;
30
54
  /** Substitute `:param` segments in an href with a document's values. */
31
55
  export declare function resolveHref(href: string, doc?: Doc): string;
32
56
  export interface DispatchOptions {
@@ -52,6 +76,8 @@ export declare function useAction(): (action: Action, opts?: DispatchOptions) =>
52
76
  export declare function formatValue(value: unknown, format: Format | undefined, opts?: {
53
77
  locale?: string | undefined;
54
78
  currency?: string | undefined;
79
+ /** The chrome catalogue, for the one format that renders words rather than digits. */
80
+ messages?: Pick<UiMessages, 'common.yes' | 'common.no'> | undefined;
55
81
  }): string;
56
82
  /**
57
83
  * A translation lookup over the catalogue a multilingual screen carries, reading the active
@@ -60,6 +86,17 @@ export declare function formatValue(value: unknown, format: Format | undefined,
60
86
  * set for all of them at once.
61
87
  */
62
88
  export declare function useMessages(messages: Record<string, Record<string, string>>, fallback: string): (key: string) => string;
89
+ /**
90
+ * Sets the browser tab's title while a screen is mounted, restoring the previous one when it
91
+ * unmounts (a screen with no title of its own must not inherit the last one's).
92
+ *
93
+ * A generated screen calls this with its OWN translated title, which is why the title is not
94
+ * taken from the `ROUTES` table: the table is module data, evaluated before any locale is
95
+ * known, so a title read from there is frozen in the document's default language. The route's
96
+ * `title` stays as the index's label and as what an external router reads; the tab follows
97
+ * the viewer's language.
98
+ */
99
+ export declare function usePageTitle(title: string | undefined): void;
63
100
  /**
64
101
  * The locale to start in: the first of the viewer's preferred languages the document has,
65
102
  * matched exactly and then by language (`es-MX` finds `es`), else the document's first —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groveback/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "React primitives for Groveback Studio screens — tables, forms, detail views and relation pickers that read through the Groveback SDK, so every query passes the policy engine.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",