@protobase/ui 0.22.9 → 0.22.11

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.
@@ -23,6 +23,7 @@ export type CurrentAddress = ListAddress | {
23
23
  key: string;
24
24
  edit?: true;
25
25
  open?: readonly string[];
26
+ tab?: string;
26
27
  } | {
27
28
  kind: 'create';
28
29
  resource: string;
@@ -35,7 +35,8 @@ const currentAddress = (read, defaults) => {
35
35
  resource: read.resource,
36
36
  key: encodeKey(read.key),
37
37
  ...read.edit && { edit: true },
38
- ...read.open && { open: read.open }
38
+ ...read.open && { open: read.open },
39
+ ...read.tab && { tab: read.tab }
39
40
  };
40
41
  if (read?.kind !== "list") return read;
41
42
  return {
@@ -1,25 +1,31 @@
1
1
  import { whenShown } from "./record-mode.js";
2
2
  import { isRecordCondition } from "@protobase/schema";
3
3
  //#region src/app/record-mode/shown-layout.ts
4
- /** The items with a record condition in `when`, those inside groups too, whose conditions the page evaluates: a group before its items. */
5
- const conditionalItems = (items) => items.flatMap((item) => item.kind === "group" ? [item, ...item.items] : [item]).filter((item) => isRecordCondition(item.when));
4
+ const innerItems = (item) => item.kind === "group" || item.kind === "tab" ? item.items : [];
5
+ const whenOf = (item) => item.kind === "tab" ? void 0 : item.when;
6
+ /** The items with a record condition in `when`, those inside tabs and groups too, whose conditions the page evaluates: a group before its items. */
7
+ const conditionalItems = (items) => items.flatMap((item) => [item, ...conditionalItems(innerItems(item))]).filter((item) => isRecordCondition(whenOf(item)));
6
8
  /**
7
9
  * The items that show: those with a record condition only when it holds (`held`, one answer per conditional item in
8
10
  * the order `conditionalItems` lists them, `undefined` while loading) and the page is no preview; the others by the
9
- * form or read view. A group keeps the items inside it that show, and goes when it or all of them do not.
11
+ * form or read view. A tab or group keeps the items inside it that show, and goes when it or all of them do not.
10
12
  */
11
13
  const shownLayout = (items, held, editing, preview) => {
12
14
  let next = 0;
13
- const shown = (item) => isRecordCondition(item.when) ? !preview && held[next++] === true : whenShown(item.when, editing);
14
- return items.flatMap((item) => {
15
- if (item.kind !== "group") return shown(item) ? [item] : [];
16
- const groupShown = shown(item);
17
- const inner = item.items.filter(shown);
18
- return groupShown && inner.length > 0 ? [{
15
+ const shown = (item) => {
16
+ const when = whenOf(item);
17
+ return isRecordCondition(when) ? !preview && held[next++] === true : whenShown(when, editing);
18
+ };
19
+ const keep = (list) => list.flatMap((item) => {
20
+ const itself = shown(item);
21
+ if (item.kind !== "group" && item.kind !== "tab") return itself ? [item] : [];
22
+ const inner = keep(item.items);
23
+ return itself && inner.length > 0 ? [{
19
24
  ...item,
20
25
  items: inner
21
26
  }] : [];
22
27
  });
28
+ return keep(items);
23
29
  };
24
30
  //#endregion
25
- export { conditionalItems, shownLayout };
31
+ export { conditionalItems, shownLayout, whenOf };
@@ -1,12 +1,12 @@
1
1
  import { useConditions } from "../pages/use-conditions.js";
2
- import { conditionalItems, shownLayout } from "./shown-layout.js";
2
+ import { conditionalItems, shownLayout, whenOf } from "./shown-layout.js";
3
3
  //#region src/app/record-mode/use-shown-layout.ts
4
4
  /**
5
5
  * The layout items to draw: those meant for the form or the read view as the page is, and those with a record
6
6
  * condition while it holds for the saved record. Conditional items stay out while their values load and on a preview.
7
7
  */
8
8
  const useShownLayout = (items, scope, editing, preview) => {
9
- const conditions = preview ? [] : conditionalItems(items).map((item) => item.when);
9
+ const conditions = preview ? [] : conditionalItems(items).map((item) => whenOf(item));
10
10
  const held = useConditions(conditions, scope);
11
11
  return shownLayout(items, held, editing, preview);
12
12
  };
@@ -22,6 +22,7 @@ import { filledFields } from "./filled-fields.js";
22
22
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
23
23
  import { AppLink, useAppAddress } from "./use-app-address.js";
24
24
  import { GroupSection } from "../record-view/group-section.js";
25
+ import { RecordTabs } from "../record-view/record-tabs.js";
25
26
  import { SpecTable } from "../record-view/spec-table.js";
26
27
  import { KeyFacts } from "../record-view/key-facts.js";
27
28
  import { fromDraft, isRequiredOnEdit } from "../live/field-values.js";
@@ -47,6 +48,7 @@ import { CustomSection } from "./custom-section.js";
47
48
  import { MapSection } from "./map/map-section.js";
48
49
  import { RecordActions } from "./pages/record-actions.js";
49
50
  import { groupOpen, toggledOpen } from "./group-open.js";
51
+ import { attentionTab, shownTab, splitTabs } from "./record-tabs.js";
50
52
  import { RecordLines, findLinesResource } from "./record-lines.js";
51
53
  import { draftStorageKey } from "./draft-storage.js";
52
54
  import { useLines } from "./use-lines.js";
@@ -60,7 +62,7 @@ import { useRelatedRecords } from "./use-related-records.js";
60
62
  import { IncomingCard } from "./incoming-card.js";
61
63
  import { RelatedSection } from "./related-section.js";
62
64
  import { relatedValue } from "./related-value.js";
63
- import { Fragment, useMemo } from "react";
65
+ import { Fragment, useEffect, useMemo } from "react";
64
66
  import { Check } from "lucide-react";
65
67
  import { isApiError } from "@protobase/client";
66
68
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -155,15 +157,17 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
155
157
  usePauseFocusRefresh(editor.dirty);
156
158
  const openGroups = address?.kind === "record" ? address.open : void 0;
157
159
  const atEdit = address?.kind === "record" && address.edit === true;
158
- const recordAt = (open = [], edit = false) => ({
160
+ const addressedTab = address?.kind === "record" ? address.tab : void 0;
161
+ const recordAt = (open = [], edit = false, tab) => ({
159
162
  kind: "record",
160
163
  resource: model.name,
161
164
  key: recordKey,
162
165
  ...edit && { edit },
163
- ...open.length > 0 && { open }
166
+ ...open.length > 0 && { open },
167
+ ...tab && { tab }
164
168
  });
165
- const readAddress = recordAt(openGroups);
166
- const editAddress = recordAt(openGroups, true);
169
+ const readAddress = recordAt(openGroups, false, addressedTab);
170
+ const editAddress = recordAt(openGroups, true, addressedTab);
167
171
  const leavesForm = shown === "form" && view?.recordMode !== void 0 && view.recordMode !== "edit";
168
172
  const canDelete = can(permissions, "delete") && stored.permissions?.delete !== false;
169
173
  const remove = useDeleteFlow({
@@ -204,6 +208,14 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
204
208
  permissions: stored.permissions
205
209
  };
206
210
  const layout = useShownLayout(layoutFor(model, view, shown === "read"), scope, editing, preview);
211
+ const { before, tabs, after } = splitTabs(layout);
212
+ const tab = shownTab(tabs, addressedTab);
213
+ const needsAttention = (name) => editing && (editor.errors[name] !== void 0 || editor.conflicts[name] !== void 0);
214
+ useEffect(() => {
215
+ if (editor.rejections === 0) return;
216
+ const target = attentionTab(tabs, tab, needsAttention);
217
+ if (target) go(recordAt(openGroups, atEdit, target === tabs[0] ? void 0 : target.title), { replace: true });
218
+ }, [editor.rejections]);
207
219
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
208
220
  const hasStatus = model.fields.status?.type === "enum";
209
221
  const statusValue = stored.record.status;
@@ -353,8 +365,8 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
353
365
  if (item.kind === "group") {
354
366
  const children = item.items.flatMap(mainItem);
355
367
  if (children.length === 0) return [];
356
- const open = groupOpen(item, openGroups, (name) => editing && (editor.errors[name] !== void 0 || editor.conflicts[name] !== void 0));
357
- const toggle = item.collapsed ? recordAt(toggledOpen(openGroups, item.title), atEdit) : void 0;
368
+ const open = groupOpen(item, openGroups, needsAttention);
369
+ const toggle = item.collapsed ? recordAt(toggledOpen(openGroups, item.title), atEdit, addressedTab) : void 0;
358
370
  return [/* @__PURE__ */ jsx(GroupSection, {
359
371
  title: item.title,
360
372
  open,
@@ -483,13 +495,26 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
483
495
  className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
484
496
  children: [/* @__PURE__ */ jsxs("main", {
485
497
  className: "divide-y divide-border",
486
- children: [layout.flatMap(mainItem), linesModel && !preview && /* @__PURE__ */ jsx(RecordLines, {
487
- lines: linesModel,
488
- draft: linesDraft,
489
- vatRate: Number(stored.record.vatRate ?? 21) / 100,
490
- saving: editor.saving,
491
- locked: shown !== "form"
492
- })]
498
+ children: [
499
+ before.flatMap(mainItem),
500
+ tab && /* @__PURE__ */ jsx(RecordTabs, { tabs: tabs.map((each, index) => ({
501
+ title: each.title,
502
+ to: recordAt(openGroups, atEdit, index === 0 ? void 0 : each.title),
503
+ current: each === tab
504
+ })) }),
505
+ tab && /* @__PURE__ */ jsx("div", {
506
+ className: "divide-y divide-border",
507
+ children: tab.items.flatMap(mainItem)
508
+ }, `tab:${tab.title}`),
509
+ after.flatMap(mainItem),
510
+ linesModel && !preview && /* @__PURE__ */ jsx(RecordLines, {
511
+ lines: linesModel,
512
+ draft: linesDraft,
513
+ vatRate: Number(stored.record.vatRate ?? 21) / 100,
514
+ saving: editor.saving,
515
+ locked: shown !== "form"
516
+ })
517
+ ]
493
518
  }), /* @__PURE__ */ jsxs("aside", {
494
519
  className: "space-y-4 md:pt-8",
495
520
  children: [
@@ -0,0 +1,31 @@
1
+ import { layoutItems } from "@protobase/schema/internal";
2
+ //#region src/app/record-tabs.ts
3
+ /** The shown layout around its tabs: the items before them, the tabs, and the items after. A view's tabs stand together. */
4
+ const splitTabs = (layout) => {
5
+ const first = layout.findIndex((item) => item.kind === "tab");
6
+ if (first === -1) return {
7
+ before: layout,
8
+ tabs: [],
9
+ after: []
10
+ };
11
+ const tabs = [];
12
+ for (const item of layout.slice(first)) {
13
+ if (item.kind !== "tab") break;
14
+ tabs.push(item);
15
+ }
16
+ return {
17
+ before: layout.slice(0, first),
18
+ tabs,
19
+ after: layout.slice(first + tabs.length)
20
+ };
21
+ };
22
+ /** The tab shown: the one the address names, else the first. */
23
+ const shownTab = (tabs, named) => tabs.find((tab) => tab.title === named) ?? tabs[0];
24
+ const tabFields = (tab) => layoutItems(tab.items).flatMap((item) => item.kind === "section" ? item.fields : []);
25
+ /** The tab to show after a turned-down save: the first that holds a field needing attention, unless the shown one does. */
26
+ const attentionTab = (tabs, shown, needsAttention) => {
27
+ if (!shown || tabFields(shown).some(needsAttention)) return void 0;
28
+ return tabs.find((tab) => tabFields(tab).some(needsAttention));
29
+ };
30
+ //#endregion
31
+ export { attentionTab, shownTab, splitTabs };
@@ -2,14 +2,21 @@ import { useEffect, useRef } from "react";
2
2
  //#region src/app/use-focus-first-invalid.ts
3
3
  /**
4
4
  * Puts focus on the first control marked invalid inside the element the ref is on, each time `rejections` counts
5
- * another save the server turned down, once the form shows why.
5
+ * another save the server turned down, once the form shows why, such as after the page moves to the tab that holds it.
6
6
  */
7
7
  const useFocusFirstInvalid = (rejections) => {
8
8
  const ref = useRef(null);
9
+ const pending = useRef(false);
9
10
  useEffect(() => {
10
- if (rejections === 0) return;
11
- ref.current?.querySelector("[aria-invalid=\"true\"]")?.focus();
11
+ if (rejections > 0) pending.current = true;
12
12
  }, [rejections]);
13
+ useEffect(() => {
14
+ if (!pending.current) return;
15
+ const invalid = ref.current?.querySelector("[aria-invalid=\"true\"]");
16
+ if (!invalid) return;
17
+ invalid.focus();
18
+ pending.current = false;
19
+ });
13
20
  return ref;
14
21
  };
15
22
  //#endregion
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.22.9";
2
+ var version = "0.22.11";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,22 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { segmentedClass } from "../primitives/segmented.js";
3
+ import { AppLink } from "../app/use-app-address.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/record-view/record-tabs.tsx
6
+ /** The record page's tabs, links whose addresses show each; the shown one is the current page. */
7
+ const RecordTabs = ({ tabs }) => /* @__PURE__ */ jsx("nav", {
8
+ "aria-label": "Tabs",
9
+ className: "overflow-x-auto py-6",
10
+ children: /* @__PURE__ */ jsx("span", {
11
+ className: segmentedClass.group("md"),
12
+ children: tabs.map((tab) => /* @__PURE__ */ jsx(AppLink, {
13
+ to: tab.to,
14
+ replace: true,
15
+ "aria-current": tab.current ? "page" : void 0,
16
+ className: cn(segmentedClass.option(tab.current, "md"), "whitespace-nowrap"),
17
+ children: tab.title
18
+ }, tab.title))
19
+ })
20
+ });
21
+ //#endregion
22
+ export { RecordTabs };