@groveback/ui 0.3.0 → 0.5.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/SignIn.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * through the same endpoints, and the same rate limits, as a hand-written form would.
11
11
  */
12
12
  import type { ReactNode } from 'react';
13
+ import type { UiMessages } from './messages';
13
14
  import type { Doc } from './types';
14
15
  /**
15
16
  * The slice of the SDK's `client.auth` the gate uses. `login` may resolve with
@@ -32,6 +33,20 @@ export interface SignInProps {
32
33
  allowRegister?: boolean | undefined;
33
34
  /** Called with the user after a successful sign-in or registration. */
34
35
  onSignedIn?: ((user: Doc) => void) | undefined;
36
+ /**
37
+ * Language for the form's own words. Optional, and normally unnecessary: under
38
+ * `<GroveRoutes>` the gate reads the shell's locale like every other primitive.
39
+ *
40
+ * It exists for the mount that wraps the WHOLE router — `<SignIn><GroveRoutes /></SignIn>`,
41
+ * the way an app puts every page behind a session. That places the form above the provider
42
+ * carrying the locale, so a Spanish app came up with an English sign-in screen in front of
43
+ * translated pages. Absent, the gate falls back to the provider when there is one and then
44
+ * to the viewer's own languages, so that mount is now translated with no prop at all; pass
45
+ * one to drive it from the app's own switcher.
46
+ */
47
+ locale?: string | undefined;
48
+ /** Overrides for the form's words, merged over the shipped catalogue (see `messages.ts`). */
49
+ messages?: Partial<UiMessages> | undefined;
35
50
  /** What a signed-in viewer sees — normally the app's routes. */
36
51
  children: ReactNode;
37
52
  }
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, useStandaloneUiText, 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, useStandaloneUiText, 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,263 @@
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 useStandaloneUiText(locale, overrides) {
142
+ const ctx = useGroveUi();
143
+ const active = locale ?? ctx.locale ?? detectLocale(Object.keys(UI_LOCALES));
144
+ const messages = { ...ctx.messages, ...overrides };
145
+ return useCallback((key) => messages[key] ?? uiMessagesFor(active)[key], [active, ctx.messages, overrides]);
146
+ }
147
+ function resolveHref(href, doc) {
148
+ if (!doc)
149
+ return href;
150
+ return href.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
151
+ const value = doc[name];
152
+ return value === undefined || value === null ? whole : String(value);
153
+ });
154
+ }
155
+ function useAction() {
156
+ const ctx = useGroveUi();
157
+ return useCallback(async (action, opts = {}) => {
158
+ switch (action.kind) {
159
+ case "navigate":
160
+ ctx.navigate(resolveHref(action.href, opts.doc));
161
+ return;
162
+ case "delete": {
163
+ const id = opts.doc?.["id"];
164
+ if (typeof id !== "string" || !opts.collection)
165
+ return;
166
+ const ask = ctx.confirm ?? ((m) => window.confirm(m));
167
+ const message = action.confirm ?? uiText(ctx, "action.confirmDelete");
168
+ if (!await ask(message))
169
+ return;
170
+ await opts.collection.delete(id);
171
+ opts.onDone?.();
172
+ return;
173
+ }
174
+ case "endpoint": {
175
+ const href = resolveHref(action.path, opts.doc);
176
+ const method = action.method ?? "POST";
177
+ if (ctx.callEndpoint)
178
+ await ctx.callEndpoint(href, method);
179
+ else
180
+ await fetch(`${ctx.endpointBase ?? ""}${href}`, { method });
181
+ opts.onDone?.();
182
+ return;
183
+ }
184
+ }
185
+ }, [ctx]);
186
+ }
187
+ var MARKDOWN_CELL_MAX = 80;
188
+ function formatValue(value, format, opts = {}) {
189
+ if (value === null || value === undefined)
190
+ return "";
191
+ switch (format) {
192
+ case "boolean":
193
+ return value ? opts.messages?.["common.yes"] ?? "Yes" : opts.messages?.["common.no"] ?? "No";
194
+ case "currency": {
195
+ const n = Number(value);
196
+ if (!Number.isFinite(n))
197
+ return String(value);
198
+ return new Intl.NumberFormat(opts.locale, {
199
+ style: "currency",
200
+ currency: opts.currency ?? "USD"
201
+ }).format(n);
202
+ }
203
+ case "number": {
204
+ const n = Number(value);
205
+ return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
206
+ }
207
+ case "date": {
208
+ const text = String(value);
209
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
210
+ const d = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(text);
211
+ return Number.isNaN(d.getTime()) ? text : d.toLocaleDateString(opts.locale);
212
+ }
213
+ case "markdown": {
214
+ const text = String(value).replace(/\s+/g, " ").trim();
215
+ if (text.length <= MARKDOWN_CELL_MAX)
216
+ return text;
217
+ const cut = text.slice(0, MARKDOWN_CELL_MAX);
218
+ const lastSpace = cut.lastIndexOf(" ");
219
+ return `${(lastSpace > MARKDOWN_CELL_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
220
+ }
221
+ default:
222
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
223
+ }
224
+ }
225
+ function useMessages(messages, fallback) {
226
+ const { locale } = useGroveUi();
227
+ return useCallback((key) => {
228
+ const entry = messages[key];
229
+ if (!entry)
230
+ return key;
231
+ return (locale !== undefined ? entry[locale] : undefined) ?? entry[fallback] ?? key;
232
+ }, [messages, locale, fallback]);
233
+ }
234
+ function usePageTitle(title) {
235
+ useEffect(() => {
236
+ if (typeof document === "undefined" || !title)
237
+ return;
238
+ const previous = document.title;
239
+ document.title = title;
240
+ return () => {
241
+ document.title = previous;
242
+ };
243
+ }, [title]);
244
+ }
245
+ function detectLocale(locales, preferred = typeof navigator !== "undefined" ? navigator.languages : []) {
246
+ const first = locales[0] ?? "en";
247
+ for (const want of preferred) {
248
+ const exact = locales.find((l) => l.toLowerCase() === want.toLowerCase());
249
+ if (exact)
250
+ return exact;
251
+ const lang = want.split("-")[0]?.toLowerCase();
252
+ const loose = locales.find((l) => l.split("-")[0]?.toLowerCase() === lang);
253
+ if (loose)
254
+ return loose;
255
+ }
256
+ return first;
257
+ }
258
+
259
+ // src/MarkdownField.tsx
260
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
7
261
  function escapeHtml(s) {
8
262
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
263
  }
@@ -46,12 +300,12 @@ function renderMarkdown(src) {
46
300
  `);
47
301
  }
48
302
  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" } }
303
+ { key: "b", label: "markdown.bold", glyph: "B", wrap: { before: "**", after: "**", placeholder: "bold" } },
304
+ { key: "i", label: "markdown.italic", glyph: "I", wrap: { before: "*", after: "*", placeholder: "italic" } },
305
+ { key: "h", label: "markdown.heading", glyph: "H", wrap: { before: "## ", after: "", placeholder: "Heading" } },
306
+ { key: "ul", label: "markdown.listItem", glyph: "•", wrap: { before: "- ", after: "", placeholder: "item" } },
307
+ { key: "code", label: "markdown.code", glyph: "</>", wrap: { before: "`", after: "`", placeholder: "code" } },
308
+ { key: "a", label: "markdown.link", glyph: "\uD83D\uDD17", wrap: { before: "[", after: "](https://)", placeholder: "text" } }
55
309
  ];
56
310
  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
311
  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 +313,7 @@ function MarkdownField({
59
313
  value,
60
314
  onChange
61
315
  }) {
316
+ const t = useUiText();
62
317
  const [mode, setMode] = useState("write");
63
318
  const [el, setEl] = useState(null);
64
319
  function apply(wrap) {
@@ -81,53 +336,53 @@ function MarkdownField({
81
336
  /* @__PURE__ */ jsxs("div", {
82
337
  className: "flex items-center gap-0.5 border-b border-neutral-200 px-1 py-1 dark:border-neutral-800",
83
338
  children: [
84
- TOOLS.map((t) => /* @__PURE__ */ jsx("button", {
339
+ TOOLS.map((tool) => /* @__PURE__ */ jsx2("button", {
85
340
  type: "button",
86
- title: t.label,
87
- "aria-label": t.label,
341
+ title: t(tool.label),
342
+ "aria-label": t(tool.label),
88
343
  disabled: mode === "preview",
89
- onClick: () => apply(t.wrap),
344
+ onClick: () => apply(tool.wrap),
90
345
  className: TOOL_BUTTON,
91
- children: t.glyph
92
- }, t.key)),
346
+ children: tool.glyph
347
+ }, tool.key)),
93
348
  /* @__PURE__ */ jsxs("div", {
94
349
  className: "ml-auto flex gap-0.5",
95
350
  children: [
96
- /* @__PURE__ */ jsx("button", {
351
+ /* @__PURE__ */ jsx2("button", {
97
352
  type: "button",
98
353
  onClick: () => setMode("write"),
99
354
  className: tab(mode === "write"),
100
- children: "Write"
355
+ children: t("markdown.write")
101
356
  }),
102
- /* @__PURE__ */ jsx("button", {
357
+ /* @__PURE__ */ jsx2("button", {
103
358
  type: "button",
104
359
  onClick: () => setMode("preview"),
105
360
  className: tab(mode === "preview"),
106
- children: "Preview"
361
+ children: t("markdown.preview")
107
362
  })
108
363
  ]
109
364
  })
110
365
  ]
111
366
  }),
112
- mode === "write" ? /* @__PURE__ */ jsx("textarea", {
367
+ mode === "write" ? /* @__PURE__ */ jsx2("textarea", {
113
368
  ref: setEl,
114
369
  rows: 8,
115
370
  value,
116
371
  spellCheck: false,
117
- placeholder: "Write Markdown…",
372
+ placeholder: t("markdown.placeholder"),
118
373
  onChange: (e) => onChange(e.target.value),
119
374
  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", {
375
+ }) : /* @__PURE__ */ jsx2("div", {
121
376
  className: PREVIEW_TYPOGRAPHY,
122
- dangerouslySetInnerHTML: { __html: renderMarkdown(value || "_Nothing to preview_") }
377
+ dangerouslySetInnerHTML: { __html: renderMarkdown(value || `_${t("markdown.nothingToPreview")}_`) }
123
378
  })
124
379
  ]
125
380
  });
126
381
  }
127
382
 
128
383
  // 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";
384
+ import { useEffect as useEffect2, useMemo as useMemo2, useRef, useState as useState2 } from "react";
385
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
131
386
  var PAGE = 200;
132
387
  function labelOf(doc, field) {
133
388
  const raw = doc[field];
@@ -139,13 +394,14 @@ function labelOf(doc, field) {
139
394
  }
140
395
  function RelationSelect(props) {
141
396
  const { collection, displayField, value, onChange, placeholder, required } = props;
397
+ const t = useUiText();
142
398
  const [docs, setDocs] = useState2(null);
143
399
  const [error, setError] = useState2(null);
144
400
  const [query, setQuery] = useState2("");
145
401
  const [open, setOpen] = useState2(false);
146
402
  const [truncated, setTruncated] = useState2(false);
147
403
  const boxRef = useRef(null);
148
- useEffect(() => {
404
+ useEffect2(() => {
149
405
  let live = true;
150
406
  collection.find({ limit: PAGE }).then((found) => {
151
407
  if (!live)
@@ -160,7 +416,7 @@ function RelationSelect(props) {
160
416
  live = false;
161
417
  };
162
418
  }, [collection]);
163
- useEffect(() => {
419
+ useEffect2(() => {
164
420
  if (!open)
165
421
  return;
166
422
  const onDown = (e) => {
@@ -170,8 +426,8 @@ function RelationSelect(props) {
170
426
  document.addEventListener("mousedown", onDown);
171
427
  return () => document.removeEventListener("mousedown", onDown);
172
428
  }, [open]);
173
- const selected = useMemo(() => (docs ?? []).find((d) => d["id"] === value) ?? null, [docs, value]);
174
- const matches = useMemo(() => {
429
+ const selected = useMemo2(() => (docs ?? []).find((d) => d["id"] === value) ?? null, [docs, value]);
430
+ const matches = useMemo2(() => {
175
431
  const all = docs ?? [];
176
432
  const q = query.trim().toLowerCase();
177
433
  if (q === "")
@@ -179,12 +435,12 @@ function RelationSelect(props) {
179
435
  return all.filter((d) => labelOf(d, displayField).toLowerCase().includes(q));
180
436
  }, [docs, displayField, query]);
181
437
  if (error) {
182
- return /* @__PURE__ */ jsx2("p", {
438
+ return /* @__PURE__ */ jsx3("p", {
183
439
  className: "text-xs text-red-600",
184
440
  children: error
185
441
  });
186
442
  }
187
- const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? "Select…";
443
+ const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? t("relation.select");
188
444
  return /* @__PURE__ */ jsxs2("div", {
189
445
  ref: boxRef,
190
446
  className: "relative",
@@ -194,11 +450,11 @@ function RelationSelect(props) {
194
450
  onClick: () => setOpen((v) => !v),
195
451
  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
452
  children: [
197
- /* @__PURE__ */ jsx2("span", {
453
+ /* @__PURE__ */ jsx3("span", {
198
454
  className: selected || value ? "" : "text-neutral-400",
199
- children: docs === null ? "Loading…" : buttonLabel
455
+ children: docs === null ? t("common.loading") : buttonLabel
200
456
  }),
201
- /* @__PURE__ */ jsx2("span", {
457
+ /* @__PURE__ */ jsx3("span", {
202
458
  "aria-hidden": true,
203
459
  className: "ml-2 text-neutral-400",
204
460
  children: "▾"
@@ -208,18 +464,18 @@ function RelationSelect(props) {
208
464
  open ? /* @__PURE__ */ jsxs2("div", {
209
465
  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
466
  children: [
211
- /* @__PURE__ */ jsx2("input", {
467
+ /* @__PURE__ */ jsx3("input", {
212
468
  autoFocus: true,
213
469
  value: query,
214
470
  onChange: (e) => setQuery(e.target.value),
215
- placeholder: "Search…",
471
+ placeholder: t("relation.search"),
216
472
  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
473
  }),
218
474
  /* @__PURE__ */ jsxs2("ul", {
219
475
  className: "max-h-56 overflow-auto py-1",
220
476
  children: [
221
- !required ? /* @__PURE__ */ jsx2("li", {
222
- children: /* @__PURE__ */ jsx2("button", {
477
+ !required ? /* @__PURE__ */ jsx3("li", {
478
+ children: /* @__PURE__ */ jsx3("button", {
223
479
  type: "button",
224
480
  onClick: () => {
225
481
  onChange("");
@@ -227,16 +483,16 @@ function RelationSelect(props) {
227
483
  setQuery("");
228
484
  },
229
485
  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"
486
+ children: t("relation.none")
231
487
  })
232
488
  }) : null,
233
- matches.length === 0 ? /* @__PURE__ */ jsx2("li", {
489
+ matches.length === 0 ? /* @__PURE__ */ jsx3("li", {
234
490
  className: "px-3 py-2 text-xs text-neutral-500",
235
- children: "No matches."
491
+ children: t("relation.noMatches")
236
492
  }) : matches.map((doc) => {
237
493
  const id = typeof doc["id"] === "string" ? doc["id"] : "";
238
- return /* @__PURE__ */ jsx2("li", {
239
- children: /* @__PURE__ */ jsx2("button", {
494
+ return /* @__PURE__ */ jsx3("li", {
495
+ children: /* @__PURE__ */ jsx3("button", {
240
496
  type: "button",
241
497
  onClick: () => {
242
498
  onChange(id);
@@ -264,126 +520,6 @@ function RelationSelect(props) {
264
520
  });
265
521
  }
266
522
 
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
523
  // src/styles.ts
388
524
  function cx(...parts) {
389
525
  return parts.filter(Boolean).join(" ");
@@ -447,6 +583,7 @@ function Text({ muted, children }) {
447
583
  function Header(props) {
448
584
  const { brand, brandHref = "/", links, actions, sticky } = props;
449
585
  const dispatch = useAction();
586
+ const t = useUiText();
450
587
  const [open, setOpen] = useState3(false);
451
588
  const go = useCallback2((href) => (e) => {
452
589
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
@@ -490,7 +627,7 @@ function Header(props) {
490
627
  hasNav ? /* @__PURE__ */ jsx4("button", {
491
628
  type: "button",
492
629
  "data-grove-nav-toggle": true,
493
- "aria-label": open ? "Close menu" : "Open menu",
630
+ "aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
494
631
  "aria-expanded": open,
495
632
  onClick: () => setOpen((v) => !v),
496
633
  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 +732,7 @@ function useAsync(load, deps) {
595
732
  const [error, setError] = useState3(null);
596
733
  const [loading, setLoading] = useState3(true);
597
734
  const [nonce, setNonce] = useState3(0);
598
- useEffect2(() => {
735
+ useEffect3(() => {
599
736
  let live = true;
600
737
  setLoading(true);
601
738
  setError(null);
@@ -626,7 +763,7 @@ function useRelations(fields, docs, resolveCollection) {
626
763
  const [map, setMap] = useState3({});
627
764
  const relations = fields.filter((f) => f.display);
628
765
  const key = JSON.stringify(relations.map((f) => f.field)) + (docs?.length ?? 0);
629
- useEffect2(() => {
766
+ useEffect3(() => {
630
767
  if (!docs || relations.length === 0 || !resolveCollection)
631
768
  return;
632
769
  let live = true;
@@ -650,25 +787,26 @@ function useRelations(fields, docs, resolveCollection) {
650
787
  }, [key, resolveCollection]);
651
788
  return map;
652
789
  }
653
- function cellValue(doc, field, relations, locale, currency) {
790
+ function cellValue(doc, field, relations, locale, currency, messages) {
654
791
  const raw = doc[field.field];
655
792
  if (field.display) {
656
793
  const id = typeof raw === "string" ? raw : "";
657
794
  const referenced = relations[field.field]?.[id];
658
795
  if (referenced)
659
- return formatValue(referenced[field.display.field], "text", { locale, currency });
796
+ return formatValue(referenced[field.display.field], "text", { locale, currency, messages });
660
797
  }
661
- return formatValue(raw, field.format, { locale, currency });
798
+ return formatValue(raw, field.format, { locale, currency, messages });
662
799
  }
663
800
  function DataTable(props) {
664
801
  const { collection, options, columns, rowHref, rowActions, emptyText, resolveRelation } = props;
665
802
  const ctx = useGroveUi();
803
+ const messages = useUiMessages();
666
804
  const { data, error, loading, reload } = useAsync(() => collection.find(options), [JSON.stringify(options)]);
667
805
  const relations = useRelations(columns, data, resolveRelation);
668
806
  if (loading)
669
807
  return /* @__PURE__ */ jsx4(Message, {
670
808
  tone: "muted",
671
- children: "Loading…"
809
+ children: messages["common.loading"]
672
810
  });
673
811
  if (error)
674
812
  return /* @__PURE__ */ jsx4(Message, {
@@ -678,7 +816,7 @@ function DataTable(props) {
678
816
  if (!data || data.length === 0)
679
817
  return /* @__PURE__ */ jsx4(Message, {
680
818
  tone: "muted",
681
- children: emptyText ?? "Nothing here yet."
819
+ children: emptyText ?? messages["table.empty"]
682
820
  });
683
821
  return /* @__PURE__ */ jsx4("div", {
684
822
  className: "w-full overflow-x-auto rounded-md border border-neutral-200 dark:border-neutral-800",
@@ -706,7 +844,7 @@ function DataTable(props) {
706
844
  children: [
707
845
  columns.map((c) => /* @__PURE__ */ jsx4("td", {
708
846
  className: "px-3 py-2",
709
- children: cellValue(doc, c, relations, ctx.locale, ctx.currency)
847
+ children: cellValue(doc, c, relations, ctx.locale, ctx.currency, messages)
710
848
  }, c.field)),
711
849
  rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("td", {
712
850
  className: "px-3 py-2",
@@ -733,12 +871,13 @@ function DataTable(props) {
733
871
  function Detail(props) {
734
872
  const { collection, id, fields, resolveRelation } = props;
735
873
  const ctx = useGroveUi();
874
+ const messages = useUiMessages();
736
875
  const { data, error, loading } = useAsync(() => collection.get(id), [id]);
737
876
  const relations = useRelations(fields, data ? [data] : null, resolveRelation);
738
877
  if (loading)
739
878
  return /* @__PURE__ */ jsx4(Message, {
740
879
  tone: "muted",
741
- children: "Loading…"
880
+ children: messages["common.loading"]
742
881
  });
743
882
  if (error)
744
883
  return /* @__PURE__ */ jsx4(Message, {
@@ -748,7 +887,7 @@ function Detail(props) {
748
887
  if (!data)
749
888
  return /* @__PURE__ */ jsx4(Message, {
750
889
  tone: "muted",
751
- children: "Not found."
890
+ children: messages["common.notFound"]
752
891
  });
753
892
  return /* @__PURE__ */ jsx4("dl", {
754
893
  className: "divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800",
@@ -764,7 +903,7 @@ function Detail(props) {
764
903
  children: String(data[f.field] ?? "")
765
904
  }) : /* @__PURE__ */ jsx4("dd", {
766
905
  className: "col-span-2 text-sm",
767
- children: cellValue(data, f, relations, ctx.locale, ctx.currency)
906
+ children: cellValue(data, f, relations, ctx.locale, ctx.currency, messages)
768
907
  })
769
908
  ]
770
909
  }, f.field))
@@ -795,13 +934,14 @@ function coerce(raw, format) {
795
934
  function Form(props) {
796
935
  const { collection, mode, id, fields, submitLabel, onSuccess, resolveRelation } = props;
797
936
  const dispatch = useAction();
937
+ const t = useUiText();
798
938
  const [values, setValues] = useState3({});
799
939
  const [error, setError] = useState3(null);
800
940
  const [busy, setBusy] = useState3(false);
801
941
  const [done, setDone] = useState3(false);
802
942
  const submitting = useRef2(false);
803
943
  const [loading, setLoading] = useState3(mode === "update");
804
- useEffect2(() => {
944
+ useEffect3(() => {
805
945
  if (mode !== "update" || !id)
806
946
  return;
807
947
  let live = true;
@@ -865,7 +1005,7 @@ function Form(props) {
865
1005
  if (loading)
866
1006
  return /* @__PURE__ */ jsx4(Message, {
867
1007
  tone: "muted",
868
- children: "Loading…"
1008
+ children: t("common.loading")
869
1009
  });
870
1010
  return /* @__PURE__ */ jsxs3("form", {
871
1011
  onSubmit: submit,
@@ -919,14 +1059,14 @@ function Form(props) {
919
1059
  }) : null,
920
1060
  done && !error ? /* @__PURE__ */ jsx4(Message, {
921
1061
  tone: "success",
922
- children: mode === "create" ? "Saved." : "Changes saved."
1062
+ children: t(mode === "create" ? "form.created" : "form.updated")
923
1063
  }) : null,
924
1064
  /* @__PURE__ */ jsx4("div", {
925
1065
  children: /* @__PURE__ */ jsx4("button", {
926
1066
  type: "submit",
927
1067
  disabled: busy,
928
1068
  className: cx(BUTTON_BASE, VARIANT.default),
929
- children: busy ? "Saving…" : submitLabel ?? "Save"
1069
+ children: busy ? t("form.saving") : submitLabel ?? t("form.save")
930
1070
  })
931
1071
  })
932
1072
  ]
@@ -934,7 +1074,7 @@ function Form(props) {
934
1074
  }
935
1075
 
936
1076
  // src/routes.tsx
937
- import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo2, useState as useState4 } from "react";
1077
+ import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo3, useState as useState4 } from "react";
938
1078
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
939
1079
  function matchRoute(path, pattern) {
940
1080
  const clean = (path.split(/[?#]/)[0] ?? "").replace(/\/+$/, "") || "/";
@@ -971,7 +1111,7 @@ function resolveRoute(routes, path) {
971
1111
  function GroveRoutes(props) {
972
1112
  const { routes, context, fallback, children, gate } = props;
973
1113
  const [path, setPath] = useState4(() => window.location.pathname);
974
- useEffect3(() => {
1114
+ useEffect4(() => {
975
1115
  const onPop = () => setPath(window.location.pathname);
976
1116
  window.addEventListener("popstate", onPop);
977
1117
  return () => window.removeEventListener("popstate", onPop);
@@ -980,7 +1120,7 @@ function GroveRoutes(props) {
980
1120
  window.history.pushState({}, "", href);
981
1121
  setPath(href.split(/[?#]/)[0] ?? href);
982
1122
  }, []);
983
- const value = useMemo2(() => ({ ...context, navigate }), [context, navigate]);
1123
+ const value = useMemo3(() => ({ ...context, navigate }), [context, navigate]);
984
1124
  const hit = resolveRoute(routes, path);
985
1125
  const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
986
1126
  const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
@@ -997,12 +1137,13 @@ function GroveRoutes(props) {
997
1137
  }
998
1138
  function NotFound() {
999
1139
  const { navigate } = useGroveUi();
1140
+ const t = useUiText();
1000
1141
  return /* @__PURE__ */ jsxs4("div", {
1001
1142
  className: "flex flex-col gap-2 p-6",
1002
1143
  children: [
1003
1144
  /* @__PURE__ */ jsx5("p", {
1004
1145
  className: "text-sm text-neutral-500",
1005
- children: "Not found."
1146
+ children: t("common.notFound")
1006
1147
  }),
1007
1148
  /* @__PURE__ */ jsx5("a", {
1008
1149
  href: "/",
@@ -1011,21 +1152,22 @@ function NotFound() {
1011
1152
  e.preventDefault();
1012
1153
  navigate("/");
1013
1154
  },
1014
- children: "Home"
1155
+ children: t("common.home")
1015
1156
  })
1016
1157
  ]
1017
1158
  });
1018
1159
  }
1019
1160
  function RouteIndex({ routes }) {
1020
1161
  const { navigate } = useGroveUi();
1162
+ const t = useUiText();
1021
1163
  const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
1022
1164
  return /* @__PURE__ */ jsxs4("nav", {
1023
1165
  className: "flex flex-col gap-3 p-6",
1024
- "aria-label": "Pages",
1166
+ "aria-label": t("common.pages"),
1025
1167
  children: [
1026
1168
  /* @__PURE__ */ jsx5("h1", {
1027
1169
  className: "text-2xl font-semibold",
1028
- children: "Pages"
1170
+ children: t("common.pages")
1029
1171
  }),
1030
1172
  /* @__PURE__ */ jsx5("ul", {
1031
1173
  className: "flex flex-col gap-1",
@@ -1055,7 +1197,16 @@ function RouteIndex({ routes }) {
1055
1197
  import { useCallback as useCallback4, useRef as useRef3, useState as useState5 } from "react";
1056
1198
  import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
1057
1199
  function SignIn(props) {
1058
- const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
1200
+ const {
1201
+ auth,
1202
+ title,
1203
+ allowRegister = typeof auth.register === "function",
1204
+ onSignedIn,
1205
+ locale,
1206
+ messages,
1207
+ children
1208
+ } = props;
1209
+ const t = useStandaloneUiText(locale, messages);
1059
1210
  const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
1060
1211
  const [mode, setMode] = useState5("login");
1061
1212
  const [email, setEmail] = useState5("");
@@ -1079,13 +1230,13 @@ function SignIn(props) {
1079
1230
  try {
1080
1231
  if (mode === "mfa") {
1081
1232
  if (!auth.verifyMfa)
1082
- throw new Error("This account requires a second factor, which this app does not support.");
1233
+ throw new Error(t("signIn.mfaUnsupported"));
1083
1234
  finish(await auth.verifyMfa(code));
1084
1235
  return;
1085
1236
  }
1086
1237
  if (mode === "register") {
1087
1238
  if (!auth.register)
1088
- throw new Error("Registration is not available.");
1239
+ throw new Error(t("signIn.registerUnavailable"));
1089
1240
  await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
1090
1241
  }
1091
1242
  const user = await auth.login(email, password);
@@ -1100,12 +1251,12 @@ function SignIn(props) {
1100
1251
  submitting.current = false;
1101
1252
  setBusy(false);
1102
1253
  }
1103
- }, [auth, code, email, finish, mode, name, password]);
1254
+ }, [auth, code, email, finish, mode, name, password, t]);
1104
1255
  if (signedIn)
1105
1256
  return /* @__PURE__ */ jsx6(Fragment2, {
1106
1257
  children
1107
1258
  });
1108
- const heading = mode === "register" ? "Create account" : mode === "mfa" ? "Two-factor code" : title ?? "Sign in";
1259
+ const heading = mode === "register" ? t("signIn.register") : mode === "mfa" ? t("signIn.mfa") : title ?? t("signIn.title");
1109
1260
  const label = "text-sm text-neutral-600 dark:text-neutral-400";
1110
1261
  return /* @__PURE__ */ jsx6("div", {
1111
1262
  className: "flex min-h-screen items-center justify-center p-6",
@@ -1123,7 +1274,7 @@ function SignIn(props) {
1123
1274
  children: [
1124
1275
  /* @__PURE__ */ jsx6("span", {
1125
1276
  className: label,
1126
- children: "Code from your authenticator app, or a backup code"
1277
+ children: t("signIn.code")
1127
1278
  }),
1128
1279
  /* @__PURE__ */ jsx6("input", {
1129
1280
  className: INPUT_CLASS,
@@ -1142,7 +1293,7 @@ function SignIn(props) {
1142
1293
  children: [
1143
1294
  /* @__PURE__ */ jsx6("span", {
1144
1295
  className: label,
1145
- children: "Name"
1296
+ children: t("signIn.name")
1146
1297
  }),
1147
1298
  /* @__PURE__ */ jsx6("input", {
1148
1299
  className: INPUT_CLASS,
@@ -1157,7 +1308,7 @@ function SignIn(props) {
1157
1308
  children: [
1158
1309
  /* @__PURE__ */ jsx6("span", {
1159
1310
  className: label,
1160
- children: "Email"
1311
+ children: t("signIn.email")
1161
1312
  }),
1162
1313
  /* @__PURE__ */ jsx6("input", {
1163
1314
  className: INPUT_CLASS,
@@ -1174,7 +1325,7 @@ function SignIn(props) {
1174
1325
  children: [
1175
1326
  /* @__PURE__ */ jsx6("span", {
1176
1327
  className: label,
1177
- children: "Password"
1328
+ children: t("signIn.password")
1178
1329
  }),
1179
1330
  /* @__PURE__ */ jsx6("input", {
1180
1331
  className: INPUT_CLASS,
@@ -1200,7 +1351,7 @@ function SignIn(props) {
1200
1351
  type: "submit",
1201
1352
  disabled: busy,
1202
1353
  className: cx(BUTTON_BASE, VARIANT.default),
1203
- children: busy ? "Please wait" : mode === "register" ? "Create account" : mode === "mfa" ? "Verify" : "Sign in"
1354
+ children: busy ? t("signIn.wait") : mode === "register" ? t("signIn.submitRegister") : mode === "mfa" ? t("signIn.submitMfa") : t("signIn.submit")
1204
1355
  }),
1205
1356
  allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
1206
1357
  type: "button",
@@ -1209,7 +1360,7 @@ function SignIn(props) {
1209
1360
  setError(null);
1210
1361
  setMode(mode === "register" ? "login" : "register");
1211
1362
  },
1212
- children: mode === "register" ? "Have an account? Sign in" : "Create an account"
1363
+ children: t(mode === "register" ? "signIn.toSignIn" : "signIn.toRegister")
1213
1364
  }) : null
1214
1365
  ]
1215
1366
  })
@@ -1240,7 +1391,12 @@ export {
1240
1391
  renderMarkdown,
1241
1392
  resolveHref,
1242
1393
  resolveRoute,
1394
+ uiMessagesFor,
1243
1395
  useAction,
1244
1396
  useGroveUi,
1245
- useMessages
1397
+ useMessages,
1398
+ usePageTitle,
1399
+ useStandaloneUiText,
1400
+ useUiMessages,
1401
+ useUiText
1246
1402
  };
@@ -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,49 @@ 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;
54
+ /**
55
+ * The chrome translator for a component that may render OUTSIDE the provider.
56
+ *
57
+ * `SignIn` is the case this exists for: the generated app can mount it around the whole
58
+ * router (`<SignIn><GroveRoutes /></SignIn>`), which puts the form ABOVE the provider that
59
+ * carries the locale — so the sign-in screen of a Spanish app came up in English while every
60
+ * page behind it was translated. Rather than make the app thread a locale through by hand,
61
+ * such a component resolves one itself: an explicit prop, else the provider when there is
62
+ * one, else the viewer's own languages.
63
+ *
64
+ * `available` is the set to match the viewer against — the locales the catalogue ships, since
65
+ * a component outside the provider has no document to ask.
66
+ */
67
+ export declare function useStandaloneUiText(locale: string | undefined, overrides: Partial<UiMessages> | undefined): (key: UiMessageKey) => string;
30
68
  /** Substitute `:param` segments in an href with a document's values. */
31
69
  export declare function resolveHref(href: string, doc?: Doc): string;
32
70
  export interface DispatchOptions {
@@ -52,6 +90,8 @@ export declare function useAction(): (action: Action, opts?: DispatchOptions) =>
52
90
  export declare function formatValue(value: unknown, format: Format | undefined, opts?: {
53
91
  locale?: string | undefined;
54
92
  currency?: string | undefined;
93
+ /** The chrome catalogue, for the one format that renders words rather than digits. */
94
+ messages?: Pick<UiMessages, 'common.yes' | 'common.no'> | undefined;
55
95
  }): string;
56
96
  /**
57
97
  * A translation lookup over the catalogue a multilingual screen carries, reading the active
@@ -60,6 +100,17 @@ export declare function formatValue(value: unknown, format: Format | undefined,
60
100
  * set for all of them at once.
61
101
  */
62
102
  export declare function useMessages(messages: Record<string, Record<string, string>>, fallback: string): (key: string) => string;
103
+ /**
104
+ * Sets the browser tab's title while a screen is mounted, restoring the previous one when it
105
+ * unmounts (a screen with no title of its own must not inherit the last one's).
106
+ *
107
+ * A generated screen calls this with its OWN translated title, which is why the title is not
108
+ * taken from the `ROUTES` table: the table is module data, evaluated before any locale is
109
+ * known, so a title read from there is frozen in the document's default language. The route's
110
+ * `title` stays as the index's label and as what an external router reads; the tab follows
111
+ * the viewer's language.
112
+ */
113
+ export declare function usePageTitle(title: string | undefined): void;
63
114
  /**
64
115
  * The locale to start in: the first of the viewer's preferred languages the document has,
65
116
  * 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.5.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",