@groveback/ui 0.4.0 → 0.7.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
  }
@@ -37,6 +37,8 @@ export declare function Stack(props: {
37
37
  }): import("react").JSX.Element;
38
38
  export declare function Grid(props: {
39
39
  columns: number;
40
+ /** Columns below the breakpoint; defaults to one. */
41
+ columnsMobile?: number;
40
42
  gap?: number;
41
43
  width?: keyof typeof WIDTH;
42
44
  children: ReactNode;
@@ -58,6 +60,23 @@ export declare function Text({ muted, children }: {
58
60
  * `href` is still real, which is what keeps middle-click, "open in new tab" and crawlers
59
61
  * working — the click handler only takes over the plain-left-click case.
60
62
  */
63
+ /**
64
+ * The control a viewer switches language with.
65
+ *
66
+ * Renders nothing unless the app actually offers a choice: fewer than two document locales, or
67
+ * no `setLocale` on the context (an app driving `locale` as a fixed prop owns it — see
68
+ * `GroveRoutes`). So a monolingual app looks exactly as it did before this existed.
69
+ *
70
+ * It offers EVERY locale the document declares, not the ones this package ships chrome for.
71
+ * The screens' own words are the substance of the page and are translated into all of them;
72
+ * hiding a language because the package has no "Save" in it would hide a page that is fully
73
+ * translated apart from one button. Chrome the catalogue lacks degrades to English, which is
74
+ * a word rather than a blank — and the `messages` override is the documented way out (D86).
75
+ *
76
+ * A native `<select>`: keyboard and screen-reader behaviour for free, and a phone renders its
77
+ * own picker instead of a menu that has to be re-implemented for touch.
78
+ */
79
+ export declare function LocaleSwitcher(): import("react").JSX.Element | null;
61
80
  export declare function Header(props: {
62
81
  brand?: ReactNode;
63
82
  brandHref?: string | undefined;
package/dist/index.d.ts CHANGED
@@ -10,14 +10,15 @@
10
10
  * convention — this package is browser React bundled by `bun build`, not backend ESM, so the
11
11
  * convention buys nothing and extensionless is what every bundler agrees on.
12
12
  */
13
- import { Button, DataTable, Detail, Divider, Form, Grid, Header, Heading, Stack, Text } from './components';
13
+ import { Button, DataTable, Detail, Divider, Form, Grid, Header, Heading, LocaleSwitcher, Stack, Text } from './components';
14
+ import { localeLabel } from './locale';
14
15
  import { MarkdownField, renderMarkdown } from './MarkdownField';
15
16
  import { RelationSelect } from './RelationSelect';
16
17
  import { GroveRoutes, RouteIndex, matchRoute, resolveRoute } from './routes';
17
18
  import { SignIn } from './SignIn';
18
19
  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
+ import { GroveUiProvider, detectLocale, formatValue, resolveHref, useAction, useGroveUi, useMessages, usePageTitle, useStandaloneUiText, useUiMessages, useUiText } from './runtime';
21
+ export { Button, DataTable, Detail, Divider, Form, Grid, GroveRoutes, GroveUiProvider, Header, Heading, LocaleSwitcher, MarkdownField, renderMarkdown, RelationSelect, RouteIndex, SignIn, Stack, Text, detectLocale, formatValue, localeLabel, matchRoute, resolveHref, resolveRoute, useAction, useGroveUi, useMessages, usePageTitle, useStandaloneUiText, uiMessagesFor, useUiMessages, useUiText, };
21
22
  export type { RelationSelectProps } from './RelationSelect';
22
23
  export type { GroveUiContext } from './runtime';
23
24
  export type { UiMessageKey, UiMessages } from './messages';
package/dist/index.js CHANGED
@@ -1,6 +1,39 @@
1
1
  // src/components.tsx
2
2
  import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
3
3
 
4
+ // src/locale.ts
5
+ var LOCALE_STORAGE_KEY = "grove.locale";
6
+ function readStoredLocale(available) {
7
+ if (typeof window === "undefined")
8
+ return;
9
+ let stored = null;
10
+ try {
11
+ stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
12
+ } catch {
13
+ return;
14
+ }
15
+ if (stored === null)
16
+ return;
17
+ return available.includes(stored) ? stored : undefined;
18
+ }
19
+ function storeLocale(locale) {
20
+ if (typeof window === "undefined")
21
+ return;
22
+ try {
23
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
24
+ } catch {}
25
+ }
26
+ function localeLabel(locale) {
27
+ try {
28
+ const names = new Intl.DisplayNames([locale], { type: "language" });
29
+ const label = names.of(locale);
30
+ if (label && label.toLowerCase() !== locale.toLowerCase()) {
31
+ return label.charAt(0).toUpperCase() + label.slice(1);
32
+ }
33
+ } catch {}
34
+ return locale;
35
+ }
36
+
4
37
  // src/MarkdownField.tsx
5
38
  import { useState } from "react";
6
39
 
@@ -17,6 +50,7 @@ var EN = {
17
50
  "common.no": "No",
18
51
  "header.openMenu": "Open menu",
19
52
  "header.closeMenu": "Close menu",
53
+ "locale.label": "Language",
20
54
  "table.empty": "Nothing here yet.",
21
55
  "form.save": "Save",
22
56
  "form.saving": "Saving…",
@@ -62,6 +96,7 @@ var ES = {
62
96
  "common.no": "No",
63
97
  "header.openMenu": "Abrir menú",
64
98
  "header.closeMenu": "Cerrar menú",
99
+ "locale.label": "Idioma",
65
100
  "table.empty": "Todavía no hay nada.",
66
101
  "form.save": "Guardar",
67
102
  "form.saving": "Guardando…",
@@ -138,6 +173,12 @@ function useUiMessages() {
138
173
  const ctx = useGroveUi();
139
174
  return useMemo(() => ({ ...uiMessagesFor(ctx.locale), ...ctx.messages }), [ctx]);
140
175
  }
176
+ function useStandaloneUiText(locale, overrides) {
177
+ const ctx = useGroveUi();
178
+ const active = locale ?? ctx.locale ?? detectLocale(Object.keys(UI_LOCALES));
179
+ const messages = { ...ctx.messages, ...overrides };
180
+ return useCallback((key) => messages[key] ?? uiMessagesFor(active)[key], [active, ctx.messages, overrides]);
181
+ }
141
182
  function resolveHref(href, doc) {
142
183
  if (!doc)
143
184
  return href;
@@ -538,6 +579,7 @@ import { jsx as jsx4, jsxs as jsxs3, Fragment } from "react/jsx-runtime";
538
579
  var WIDTH_CLASS = { narrow: "w-[12%]", normal: "", wide: "w-[45%]" };
539
580
  var GAP = ["gap-0", "gap-1", "gap-2", "gap-3", "gap-4", "gap-5", "gap-6", "gap-7", "gap-8", "gap-9", "gap-10", "gap-11", "gap-12"];
540
581
  var COLUMNS = ["", "md:grid-cols-1", "md:grid-cols-2", "md:grid-cols-3", "md:grid-cols-4", "md:grid-cols-5", "md:grid-cols-6"];
582
+ var BASE_COLUMNS = ["", "grid-cols-1", "grid-cols-2", "grid-cols-3", "grid-cols-4", "grid-cols-5", "grid-cols-6"];
541
583
  var WIDTH = { full: "w-full", container: "w-full max-w-5xl mx-auto", narrow: "w-full max-w-xl mx-auto" };
542
584
  var ALIGN = { start: "items-start", center: "items-center", end: "items-end", stretch: "items-stretch" };
543
585
  var JUSTIFY = { start: "justify-start", center: "justify-center", end: "justify-end", between: "justify-between" };
@@ -549,9 +591,13 @@ function Stack(props) {
549
591
  });
550
592
  }
551
593
  function Grid(props) {
552
- const { columns, gap = 4, width, children } = props;
594
+ const { columns, columnsMobile, gap = 4, width, children } = props;
595
+ const mobile = Math.min(Math.max(columnsMobile ?? 1, 1), 6);
596
+ const desktop = Math.min(Math.max(columns, 1), 6);
553
597
  return /* @__PURE__ */ jsx4("div", {
554
- className: cx("grid grid-cols-1", COLUMNS[Math.min(Math.max(columns, 1), 6)], GAP[Math.min(Math.max(gap, 0), 12)], width && WIDTH[width]),
598
+ "data-grove-cols": desktop,
599
+ "data-grove-cols-mobile": mobile,
600
+ className: cx("grid", BASE_COLUMNS[mobile], COLUMNS[desktop], GAP[Math.min(Math.max(gap, 0), 12)], width && WIDTH[width]),
555
601
  children
556
602
  });
557
603
  }
@@ -574,11 +620,30 @@ function Text({ muted, children }) {
574
620
  children
575
621
  });
576
622
  }
623
+ function LocaleSwitcher() {
624
+ const { locale, locales, setLocale } = useGroveUi();
625
+ const t = useUiText();
626
+ if (!setLocale || !locales || locales.length < 2)
627
+ return null;
628
+ return /* @__PURE__ */ jsx4("select", {
629
+ "data-grove-locale": true,
630
+ "aria-label": t("locale.label"),
631
+ value: locale ?? locales[0],
632
+ onChange: (e) => setLocale(e.target.value),
633
+ className: cx(INPUT_CLASS, "py-1 pr-7 text-xs"),
634
+ children: locales.map((tag) => /* @__PURE__ */ jsx4("option", {
635
+ value: tag,
636
+ children: localeLabel(tag)
637
+ }, tag))
638
+ });
639
+ }
577
640
  function Header(props) {
578
641
  const { brand, brandHref = "/", links, actions, sticky } = props;
579
642
  const dispatch = useAction();
580
643
  const t = useUiText();
644
+ const { onHeaderMounted } = useGroveUi();
581
645
  const [open, setOpen] = useState3(false);
646
+ useEffect3(() => onHeaderMounted?.(), [onHeaderMounted]);
582
647
  const go = useCallback2((href) => (e) => {
583
648
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
584
649
  return;
@@ -609,70 +674,76 @@ function Header(props) {
609
674
  children: link.label
610
675
  }, i))
611
676
  }) : null,
612
- actions && actions.length > 0 ? /* @__PURE__ */ jsx4("div", {
613
- "data-grove-nav-actions": true,
614
- className: "ml-auto hidden items-center gap-2 md:flex",
615
- children: actions.map((a, i) => /* @__PURE__ */ jsx4(Button, {
616
- action: a.action,
617
- variant: a.variant,
618
- children: a.label
619
- }, i))
620
- }) : null,
621
- hasNav ? /* @__PURE__ */ jsx4("button", {
622
- type: "button",
623
- "data-grove-nav-toggle": true,
624
- "aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
625
- "aria-expanded": open,
626
- onClick: () => setOpen((v) => !v),
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",
628
- children: /* @__PURE__ */ jsx4("svg", {
629
- width: "20",
630
- height: "20",
631
- viewBox: "0 0 24 24",
632
- fill: "none",
633
- stroke: "currentColor",
634
- strokeWidth: "2",
635
- strokeLinecap: "round",
636
- "aria-hidden": "true",
637
- children: open ? /* @__PURE__ */ jsxs3(Fragment, {
638
- children: [
639
- /* @__PURE__ */ jsx4("line", {
640
- x1: "18",
641
- y1: "6",
642
- x2: "6",
643
- y2: "18"
644
- }),
645
- /* @__PURE__ */ jsx4("line", {
646
- x1: "6",
647
- y1: "6",
648
- x2: "18",
649
- y2: "18"
650
- })
651
- ]
652
- }) : /* @__PURE__ */ jsxs3(Fragment, {
653
- children: [
654
- /* @__PURE__ */ jsx4("line", {
655
- x1: "3",
656
- y1: "6",
657
- x2: "21",
658
- y2: "6"
659
- }),
660
- /* @__PURE__ */ jsx4("line", {
661
- x1: "3",
662
- y1: "12",
663
- x2: "21",
664
- y2: "12"
665
- }),
666
- /* @__PURE__ */ jsx4("line", {
667
- x1: "3",
668
- y1: "18",
669
- x2: "21",
670
- y2: "18"
677
+ /* @__PURE__ */ jsxs3("div", {
678
+ className: "ml-auto flex items-center gap-2",
679
+ children: [
680
+ actions && actions.length > 0 ? /* @__PURE__ */ jsx4("div", {
681
+ "data-grove-nav-actions": true,
682
+ className: "hidden items-center gap-2 md:flex",
683
+ children: actions.map((a, i) => /* @__PURE__ */ jsx4(Button, {
684
+ action: a.action,
685
+ variant: a.variant,
686
+ children: a.label
687
+ }, i))
688
+ }) : null,
689
+ /* @__PURE__ */ jsx4(LocaleSwitcher, {}),
690
+ hasNav ? /* @__PURE__ */ jsx4("button", {
691
+ type: "button",
692
+ "data-grove-nav-toggle": true,
693
+ "aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
694
+ "aria-expanded": open,
695
+ onClick: () => setOpen((v) => !v),
696
+ className: "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",
697
+ children: /* @__PURE__ */ jsx4("svg", {
698
+ width: "20",
699
+ height: "20",
700
+ viewBox: "0 0 24 24",
701
+ fill: "none",
702
+ stroke: "currentColor",
703
+ strokeWidth: "2",
704
+ strokeLinecap: "round",
705
+ "aria-hidden": "true",
706
+ children: open ? /* @__PURE__ */ jsxs3(Fragment, {
707
+ children: [
708
+ /* @__PURE__ */ jsx4("line", {
709
+ x1: "18",
710
+ y1: "6",
711
+ x2: "6",
712
+ y2: "18"
713
+ }),
714
+ /* @__PURE__ */ jsx4("line", {
715
+ x1: "6",
716
+ y1: "6",
717
+ x2: "18",
718
+ y2: "18"
719
+ })
720
+ ]
721
+ }) : /* @__PURE__ */ jsxs3(Fragment, {
722
+ children: [
723
+ /* @__PURE__ */ jsx4("line", {
724
+ x1: "3",
725
+ y1: "6",
726
+ x2: "21",
727
+ y2: "6"
728
+ }),
729
+ /* @__PURE__ */ jsx4("line", {
730
+ x1: "3",
731
+ y1: "12",
732
+ x2: "21",
733
+ y2: "12"
734
+ }),
735
+ /* @__PURE__ */ jsx4("line", {
736
+ x1: "3",
737
+ y1: "18",
738
+ x2: "21",
739
+ y2: "18"
740
+ })
741
+ ]
671
742
  })
672
- ]
673
- })
674
- })
675
- }) : null
743
+ })
744
+ }) : null
745
+ ]
746
+ })
676
747
  ]
677
748
  }),
678
749
  hasNav && open ? /* @__PURE__ */ jsxs3("div", {
@@ -1105,6 +1176,13 @@ function resolveRoute(routes, path) {
1105
1176
  function GroveRoutes(props) {
1106
1177
  const { routes, context, fallback, children, gate } = props;
1107
1178
  const [path, setPath] = useState4(() => window.location.pathname);
1179
+ const offered = context?.locales;
1180
+ const pinned = context?.locale;
1181
+ const [chosen, setChosen] = useState4(() => pinned !== undefined || !offered || offered.length === 0 ? undefined : readStoredLocale(offered) ?? detectLocale(offered));
1182
+ const setLocale = useCallback3((next) => {
1183
+ setChosen(next);
1184
+ storeLocale(next);
1185
+ }, []);
1108
1186
  useEffect4(() => {
1109
1187
  const onPop = () => setPath(window.location.pathname);
1110
1188
  window.addEventListener("popstate", onPop);
@@ -1114,7 +1192,23 @@ function GroveRoutes(props) {
1114
1192
  window.history.pushState({}, "", href);
1115
1193
  setPath(href.split(/[?#]/)[0] ?? href);
1116
1194
  }, []);
1117
- const value = useMemo3(() => ({ ...context, navigate }), [context, navigate]);
1195
+ const active = chosen ?? pinned;
1196
+ useEffect4(() => {
1197
+ if (active !== undefined)
1198
+ document.documentElement.lang = active;
1199
+ }, [active]);
1200
+ const [headers, setHeaders] = useState4(0);
1201
+ const onHeaderMounted = useCallback3(() => {
1202
+ setHeaders((n) => n + 1);
1203
+ return () => setHeaders((n) => n - 1);
1204
+ }, []);
1205
+ const value = useMemo3(() => ({
1206
+ ...context,
1207
+ navigate,
1208
+ onHeaderMounted,
1209
+ ...chosen !== undefined ? { locale: chosen } : {},
1210
+ ...pinned === undefined && offered && offered.length > 1 ? { setLocale } : {}
1211
+ }), [context, navigate, chosen, pinned, offered, setLocale, onHeaderMounted]);
1118
1212
  const hit = resolveRoute(routes, path);
1119
1213
  const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
1120
1214
  const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
@@ -1125,7 +1219,11 @@ function GroveRoutes(props) {
1125
1219
  value,
1126
1220
  children: [
1127
1221
  children,
1128
- screen
1222
+ screen,
1223
+ headers === 0 ? /* @__PURE__ */ jsx5("div", {
1224
+ className: "fixed right-3 bottom-3 z-50",
1225
+ children: /* @__PURE__ */ jsx5(LocaleSwitcher, {})
1226
+ }) : null
1129
1227
  ]
1130
1228
  });
1131
1229
  }
@@ -1191,8 +1289,16 @@ function RouteIndex({ routes }) {
1191
1289
  import { useCallback as useCallback4, useRef as useRef3, useState as useState5 } from "react";
1192
1290
  import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
1193
1291
  function SignIn(props) {
1194
- const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
1195
- const t = useUiText();
1292
+ const {
1293
+ auth,
1294
+ title,
1295
+ allowRegister = typeof auth.register === "function",
1296
+ onSignedIn,
1297
+ locale,
1298
+ messages,
1299
+ children
1300
+ } = props;
1301
+ const t = useStandaloneUiText(locale, messages);
1196
1302
  const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
1197
1303
  const [mode, setMode] = useState5("login");
1198
1304
  const [email, setEmail] = useState5("");
@@ -1365,6 +1471,7 @@ export {
1365
1471
  GroveUiProvider,
1366
1472
  Header,
1367
1473
  Heading,
1474
+ LocaleSwitcher,
1368
1475
  MarkdownField,
1369
1476
  RelationSelect,
1370
1477
  RouteIndex,
@@ -1373,6 +1480,7 @@ export {
1373
1480
  Text,
1374
1481
  detectLocale,
1375
1482
  formatValue,
1483
+ localeLabel,
1376
1484
  matchRoute,
1377
1485
  renderMarkdown,
1378
1486
  resolveHref,
@@ -1382,6 +1490,7 @@ export {
1382
1490
  useGroveUi,
1383
1491
  useMessages,
1384
1492
  usePageTitle,
1493
+ useStandaloneUiText,
1385
1494
  useUiMessages,
1386
1495
  useUiText
1387
1496
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Remembering the viewer's chosen language.
3
+ *
4
+ * Pure and storage-aware but React-free, so the rules below are testable without a renderer —
5
+ * the same split the dashboard uses between its `i18n.ts` and `i18n-context.tsx`.
6
+ *
7
+ * Two rules matter more than the storage itself:
8
+ * - A stored value is never trusted. It is user-editable, and it outlives the document that
9
+ * produced it: an app that dropped `de` must not keep serving German because a browser
10
+ * still remembers it. Anything not on offer falls back rather than sticking.
11
+ * - Storage is allowed to fail. It throws in a private window and when site data is blocked,
12
+ * and a language preference is never worth taking the page down for.
13
+ */
14
+ /**
15
+ * Where the choice lives. Namespaced under `grove.` like the generated app's own
16
+ * `grove.session`, and deliberately NOT the dashboard's `groveback.ui.locale`: this ships
17
+ * INTO a user's app, which may be served from the same origin as nothing in particular.
18
+ */
19
+ export declare const LOCALE_STORAGE_KEY = "grove.locale";
20
+ /** The stored choice, if it is still one of `available`. */
21
+ export declare function readStoredLocale(available: readonly string[]): string | undefined;
22
+ /** Remember a choice. A failed write costs the memory, never the switch itself. */
23
+ export declare function storeLocale(locale: string): void;
24
+ /**
25
+ * How a language names ITSELF — "Español", not "Spanish". The person hunting for the switcher
26
+ * is the one who cannot read the language currently on screen, so every option is rendered in
27
+ * its own tongue.
28
+ *
29
+ * Document locales are arbitrary validated tags, so no shipped label table could cover them;
30
+ * `Intl.DisplayNames` knows them, and the raw tag is the honest fallback when it does not.
31
+ */
32
+ export declare function localeLabel(locale: string): string;
@@ -26,6 +26,8 @@ export interface UiMessages {
26
26
  'common.no': string;
27
27
  'header.openMenu': string;
28
28
  'header.closeMenu': string;
29
+ /** Accessible name of the language switcher — the options label themselves. */
30
+ 'locale.label': string;
29
31
  'table.empty': string;
30
32
  'form.save': string;
31
33
  'form.saving': string;
package/dist/runtime.d.ts CHANGED
@@ -21,6 +21,24 @@ export interface GroveUiContext {
21
21
  /** Confirmation gate for destructive actions; defaults to window.confirm. */
22
22
  confirm?: (message: string) => boolean | Promise<boolean>;
23
23
  locale?: string;
24
+ /**
25
+ * Every locale the document offers, default first — what a switcher puts on the menu.
26
+ * Absent, or shorter than two entries, means a monolingual app: no switcher renders.
27
+ */
28
+ locales?: readonly string[] | undefined;
29
+ /**
30
+ * Switches the app's language. Supplied by `GroveRoutes`, which holds the locale in state
31
+ * and persists the choice; absent when an app drives `locale` itself as a fixed prop, and
32
+ * `LocaleSwitcher` then renders nothing rather than offering a control that does nothing.
33
+ */
34
+ setLocale?: ((locale: string) => void) | undefined;
35
+ /**
36
+ * Called by `Header` while it is mounted — it returns its own cleanup, so the call site is
37
+ * a one-liner `useEffect`. The shell counts headers to decide whether to float a language
38
+ * switcher of its own: a document with no header, or a screen that opted out, renders none,
39
+ * and without this a multilingual app would ship a switcher nothing puts on the page.
40
+ */
41
+ onHeaderMounted?: (() => () => void) | undefined;
24
42
  currency?: string;
25
43
  /**
26
44
  * Overrides for the primitives' OWN strings (`messages.ts`) — the chrome an app never
@@ -51,6 +69,20 @@ export declare function uiText(ctx: GroveUiContext, key: UiMessageKey): string;
51
69
  export declare function useUiText(): (key: UiMessageKey) => string;
52
70
  /** The chrome catalogue in force — for the few places that need several keys at once. */
53
71
  export declare function useUiMessages(): UiMessages;
72
+ /**
73
+ * The chrome translator for a component that may render OUTSIDE the provider.
74
+ *
75
+ * `SignIn` is the case this exists for: the generated app can mount it around the whole
76
+ * router (`<SignIn><GroveRoutes /></SignIn>`), which puts the form ABOVE the provider that
77
+ * carries the locale — so the sign-in screen of a Spanish app came up in English while every
78
+ * page behind it was translated. Rather than make the app thread a locale through by hand,
79
+ * such a component resolves one itself: an explicit prop, else the provider when there is
80
+ * one, else the viewer's own languages.
81
+ *
82
+ * `available` is the set to match the viewer against — the locales the catalogue ships, since
83
+ * a component outside the provider has no document to ask.
84
+ */
85
+ export declare function useStandaloneUiText(locale: string | undefined, overrides: Partial<UiMessages> | undefined): (key: UiMessageKey) => string;
54
86
  /** Substitute `:param` segments in an href with a document's values. */
55
87
  export declare function resolveHref(href: string, doc?: Doc): string;
56
88
  export interface DispatchOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groveback/ui",
3
- "version": "0.4.0",
3
+ "version": "0.7.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",