@groveback/ui 0.5.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.
@@ -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
20
  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, };
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…",
@@ -544,6 +579,7 @@ import { jsx as jsx4, jsxs as jsxs3, Fragment } from "react/jsx-runtime";
544
579
  var WIDTH_CLASS = { narrow: "w-[12%]", normal: "", wide: "w-[45%]" };
545
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"];
546
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"];
547
583
  var WIDTH = { full: "w-full", container: "w-full max-w-5xl mx-auto", narrow: "w-full max-w-xl mx-auto" };
548
584
  var ALIGN = { start: "items-start", center: "items-center", end: "items-end", stretch: "items-stretch" };
549
585
  var JUSTIFY = { start: "justify-start", center: "justify-center", end: "justify-end", between: "justify-between" };
@@ -555,9 +591,13 @@ function Stack(props) {
555
591
  });
556
592
  }
557
593
  function Grid(props) {
558
- 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);
559
597
  return /* @__PURE__ */ jsx4("div", {
560
- 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]),
561
601
  children
562
602
  });
563
603
  }
@@ -580,11 +620,30 @@ function Text({ muted, children }) {
580
620
  children
581
621
  });
582
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
+ }
583
640
  function Header(props) {
584
641
  const { brand, brandHref = "/", links, actions, sticky } = props;
585
642
  const dispatch = useAction();
586
643
  const t = useUiText();
644
+ const { onHeaderMounted } = useGroveUi();
587
645
  const [open, setOpen] = useState3(false);
646
+ useEffect3(() => onHeaderMounted?.(), [onHeaderMounted]);
588
647
  const go = useCallback2((href) => (e) => {
589
648
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
590
649
  return;
@@ -615,70 +674,76 @@ function Header(props) {
615
674
  children: link.label
616
675
  }, i))
617
676
  }) : null,
618
- actions && actions.length > 0 ? /* @__PURE__ */ jsx4("div", {
619
- "data-grove-nav-actions": true,
620
- className: "ml-auto hidden items-center gap-2 md:flex",
621
- children: actions.map((a, i) => /* @__PURE__ */ jsx4(Button, {
622
- action: a.action,
623
- variant: a.variant,
624
- children: a.label
625
- }, i))
626
- }) : null,
627
- hasNav ? /* @__PURE__ */ jsx4("button", {
628
- type: "button",
629
- "data-grove-nav-toggle": true,
630
- "aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
631
- "aria-expanded": open,
632
- onClick: () => setOpen((v) => !v),
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",
634
- children: /* @__PURE__ */ jsx4("svg", {
635
- width: "20",
636
- height: "20",
637
- viewBox: "0 0 24 24",
638
- fill: "none",
639
- stroke: "currentColor",
640
- strokeWidth: "2",
641
- strokeLinecap: "round",
642
- "aria-hidden": "true",
643
- children: open ? /* @__PURE__ */ jsxs3(Fragment, {
644
- children: [
645
- /* @__PURE__ */ jsx4("line", {
646
- x1: "18",
647
- y1: "6",
648
- x2: "6",
649
- y2: "18"
650
- }),
651
- /* @__PURE__ */ jsx4("line", {
652
- x1: "6",
653
- y1: "6",
654
- x2: "18",
655
- y2: "18"
656
- })
657
- ]
658
- }) : /* @__PURE__ */ jsxs3(Fragment, {
659
- children: [
660
- /* @__PURE__ */ jsx4("line", {
661
- x1: "3",
662
- y1: "6",
663
- x2: "21",
664
- y2: "6"
665
- }),
666
- /* @__PURE__ */ jsx4("line", {
667
- x1: "3",
668
- y1: "12",
669
- x2: "21",
670
- y2: "12"
671
- }),
672
- /* @__PURE__ */ jsx4("line", {
673
- x1: "3",
674
- y1: "18",
675
- x2: "21",
676
- 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
+ ]
677
742
  })
678
- ]
679
- })
680
- })
681
- }) : null
743
+ })
744
+ }) : null
745
+ ]
746
+ })
682
747
  ]
683
748
  }),
684
749
  hasNav && open ? /* @__PURE__ */ jsxs3("div", {
@@ -1111,6 +1176,13 @@ function resolveRoute(routes, path) {
1111
1176
  function GroveRoutes(props) {
1112
1177
  const { routes, context, fallback, children, gate } = props;
1113
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
+ }, []);
1114
1186
  useEffect4(() => {
1115
1187
  const onPop = () => setPath(window.location.pathname);
1116
1188
  window.addEventListener("popstate", onPop);
@@ -1120,7 +1192,23 @@ function GroveRoutes(props) {
1120
1192
  window.history.pushState({}, "", href);
1121
1193
  setPath(href.split(/[?#]/)[0] ?? href);
1122
1194
  }, []);
1123
- 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]);
1124
1212
  const hit = resolveRoute(routes, path);
1125
1213
  const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
1126
1214
  const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
@@ -1131,7 +1219,11 @@ function GroveRoutes(props) {
1131
1219
  value,
1132
1220
  children: [
1133
1221
  children,
1134
- 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
1135
1227
  ]
1136
1228
  });
1137
1229
  }
@@ -1379,6 +1471,7 @@ export {
1379
1471
  GroveUiProvider,
1380
1472
  Header,
1381
1473
  Heading,
1474
+ LocaleSwitcher,
1382
1475
  MarkdownField,
1383
1476
  RelationSelect,
1384
1477
  RouteIndex,
@@ -1387,6 +1480,7 @@ export {
1387
1480
  Text,
1388
1481
  detectLocale,
1389
1482
  formatValue,
1483
+ localeLabel,
1390
1484
  matchRoute,
1391
1485
  renderMarkdown,
1392
1486
  resolveHref,
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groveback/ui",
3
- "version": "0.5.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",