@gogitcms/design-system 0.16.0-next.7 → 0.16.0-next.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/design-system",
3
- "version": "0.16.0-next.7",
3
+ "version": "0.16.0-next.9",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "// exports": "The root entry is the react-native source the SPAs, desktop app and mobile app consume. ./web is the plain-DOM build for server-rendered surfaces (the Astro docs site) that don't run react-native-web, and ./css ships the tokens as custom properties. The trailing wildcard keeps deep paths resolvable — plugin bundling and the Vite aliases reach into src/ directly.",
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { BranchDeletedModal } from "../components/BranchDeletedModal";
5
+
6
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
7
+
8
+ const others = [
9
+ { id: "b-main", name: "main" },
10
+ { id: "b-draft", name: "draft/spring" },
11
+ ];
12
+
13
+ test("names the deleted branch and switches to the one picked", () => {
14
+ const onSwitch = jest.fn();
15
+ render(wrap(
16
+ <BranchDeletedModal branch="feat/pricing" repository="acme/site" branches={others} onSwitch={onSwitch} />,
17
+ ));
18
+
19
+ expect(screen.getByTestId("branch-deleted-title")).toHaveTextContent("feat/pricing was deleted");
20
+ expect(screen.getByText(/removed with it/)).toBeInTheDocument();
21
+
22
+ fireEvent.click(screen.getByTestId("branch-deleted-switch-b-draft"));
23
+ expect(onSwitch).toHaveBeenCalledWith(others[1]);
24
+ // The repository exit is not offered unless the host provides it.
25
+ expect(screen.queryByTestId("branch-deleted-choose-repository")).toBeNull();
26
+ });
27
+
28
+ test("a stale link (no branch name) still explains and offers the others", () => {
29
+ render(wrap(<BranchDeletedModal branches={others} onSwitch={() => {}} />));
30
+ expect(screen.getByTestId("branch-deleted-title")).toHaveTextContent("This branch no longer exists");
31
+ expect(screen.getByTestId("branch-deleted-switch-b-main")).toBeInTheDocument();
32
+ });
33
+
34
+ test("with nothing to switch to, the repository picker is the exit", () => {
35
+ const onChoose = jest.fn();
36
+ render(wrap(
37
+ <BranchDeletedModal branch="main" repository="acme/site" branches={[]} onSwitch={() => {}} onChooseRepository={onChoose} />,
38
+ ));
39
+ expect(screen.getByTestId("branch-deleted-none")).toHaveTextContent("No other branch of acme/site");
40
+ fireEvent.click(screen.getByTestId("branch-deleted-choose-repository"));
41
+ expect(onChoose).toHaveBeenCalled();
42
+ });
@@ -0,0 +1,109 @@
1
+ // Which field the author is in, reported as a dotted path — what a preview
2
+ // pane uses to ring the matching part of the page. Read off the DOM's focus
3
+ // events and the `data-cms-field` attribute every control's wrapper carries,
4
+ // so it works for every kind of control and for a lone author with no live
5
+ // session.
6
+ import React from "react";
7
+ import { render, screen, fireEvent, act } from "@testing-library/react";
8
+ import { ThemeProvider } from "../ThemeProvider";
9
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
10
+
11
+ jest.mock("../ThemeProvider", () => {
12
+ const actual = jest.requireActual("../ThemeProvider");
13
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
14
+ });
15
+
16
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
17
+
18
+ const sections: CmsNavSection[] = [
19
+ { title: "Content", items: [{ key: "pages", label: "Pages", icon: "newspaper" }] },
20
+ ];
21
+
22
+ const fields: EntryField[] = [
23
+ { name: "title", label: "Title", type: "string", value: "Home", required: true },
24
+ {
25
+ name: "blocks",
26
+ label: "Blocks",
27
+ type: "array",
28
+ component: "mixedList",
29
+ value: [{ _variant: "hero", heading: "Hi", stats: [{ label: "a", value: "1" }] }],
30
+ variants: [
31
+ {
32
+ name: "hero",
33
+ fields: [
34
+ { name: "heading", label: "Heading", type: "string", value: null },
35
+ {
36
+ name: "stats",
37
+ label: "Stats",
38
+ type: "array",
39
+ of: "object",
40
+ value: null,
41
+ fields: [
42
+ { name: "label", label: "Label", type: "string", value: null },
43
+ { name: "value", label: "Value", type: "string", value: null },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ const entry: CmsEntry = { id: "doc-1", path: "content/pages/home.json", title: "Home", body: "", fields };
53
+
54
+ function renderBrowser(onFieldFocus = jest.fn()) {
55
+ render(
56
+ wrap(
57
+ <ContentBrowser
58
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
59
+ sections={sections}
60
+ activeNavKey="pages"
61
+ onSelectNav={() => {}}
62
+ entries={[entry]}
63
+ userInitials="ED"
64
+ onSaveEntry={jest.fn()}
65
+ onFieldFocus={onFieldFocus}
66
+ />,
67
+ ),
68
+ );
69
+ return onFieldFocus;
70
+ }
71
+
72
+ // The blur report is deferred a tick so a move between fields never passes
73
+ // through null; flush it.
74
+ const settle = () => act(() => new Promise((r) => setTimeout(r, 5)));
75
+
76
+ describe("onFieldFocus", () => {
77
+ it("reports a top-level field by name and null when it blurs", async () => {
78
+ const onFieldFocus = renderBrowser();
79
+ const title = screen.getByDisplayValue("Home");
80
+ fireEvent.focusIn(title);
81
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "title");
82
+ fireEvent.focusOut(title);
83
+ await settle();
84
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", null);
85
+ });
86
+
87
+ it("composes the path of a field inside a mixed-list item and a nested list", async () => {
88
+ const onFieldFocus = renderBrowser();
89
+ const heading = screen.getByDisplayValue("Hi");
90
+ fireEvent.focusIn(heading);
91
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.heading");
92
+
93
+ const statLabel = screen.getByDisplayValue("a");
94
+ // Straight from one field to the next: no null in between.
95
+ fireEvent.focusOut(heading);
96
+ fireEvent.focusIn(statLabel);
97
+ await settle();
98
+ expect(onFieldFocus).not.toHaveBeenCalledWith("doc-1", null);
99
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.stats.0.label");
100
+ });
101
+
102
+ it("annotates every control's wrapper with its path", () => {
103
+ renderBrowser();
104
+ const paths = Array.from(document.querySelectorAll("[data-cms-field]")).map((el) => el.getAttribute("data-cms-field"));
105
+ expect(paths).toEqual(
106
+ expect.arrayContaining(["title", "blocks", "blocks.0", "blocks.0.heading", "blocks.0.stats", "blocks.0.stats.0", "blocks.0.stats.0.label", "blocks.0.stats.0.value"]),
107
+ );
108
+ });
109
+ });
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { View, ScrollView } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Button } from "./Button";
7
+ import type { BranchRef } from "./BranchMenu";
8
+
9
+ export type BranchDeletedModalProps = {
10
+ /**
11
+ * The branch that no longer exists. Omitted when only its id is known — a
12
+ * link into a branch that was deleted before the page loaded.
13
+ */
14
+ branch?: string;
15
+ /** "owner/name", when the host knows it. */
16
+ repository?: string;
17
+ /** The branches still connected, to switch to. The deleted one is not among them. */
18
+ branches: BranchRef[];
19
+ /** Open the editor on `branch` instead. */
20
+ onSwitch: (branch: BranchRef) => void;
21
+ /**
22
+ * Leave this repository for the picker. Offered as the way out when there is
23
+ * no other branch to switch to; omitted → the list is the only exit.
24
+ */
25
+ onChooseRepository?: () => void;
26
+ testID?: string;
27
+ };
28
+
29
+ /**
30
+ * Shown when the branch the editor is on stops existing — deleted on the
31
+ * provider, or disconnected from the CMS — while someone is looking at it.
32
+ *
33
+ * It is a modal rather than a redirect because the screen behind it may hold
34
+ * work in progress. Nothing on it can be saved to this branch any more, but
35
+ * bouncing the user to another branch without a word would make that content
36
+ * vanish mid-thought; the modal says what happened and leaves the page visible
37
+ * while they decide where to go. It cannot be dismissed into the dead branch,
38
+ * because there is nothing there to go back to: every exit is another branch.
39
+ */
40
+ export function BranchDeletedModal({
41
+ branch, repository, branches, onSwitch, onChooseRepository, testID,
42
+ }: BranchDeletedModalProps) {
43
+ const t = useTheme();
44
+ const title = branch ? `${branch} was deleted` : "This branch no longer exists";
45
+ const where = repository ? ` in ${repository}` : "";
46
+ const detail = branch
47
+ ? `The ${branch} branch${where} was deleted on the remote or disconnected from the CMS, and its content was removed with it. Nothing on this screen can be saved to it any more.`
48
+ : `The branch this link points to${where} was deleted on the remote or disconnected from the CMS, so there is nothing here to edit.`;
49
+ const hasOthers = branches.length > 0;
50
+
51
+ return (
52
+ <View
53
+ testID={testID ?? "branch-deleted-modal"}
54
+ style={{
55
+ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
56
+ alignItems: "center", justifyContent: "center", padding: t.space(4),
57
+ backgroundColor: "rgba(0,0,0,0.45)",
58
+ }}
59
+ >
60
+ <View
61
+ style={{
62
+ width: 460, maxWidth: "100%", maxHeight: "100%", gap: t.space(4), padding: t.space(5),
63
+ borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
64
+ backgroundColor: t.color.surfaceRaised,
65
+ }}
66
+ >
67
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
68
+ <Icon name="alert" size={16} color={t.color.diffDelFg} />
69
+ <Text variant="h3" weight="semibold" testID="branch-deleted-title">{title}</Text>
70
+ </View>
71
+
72
+ <Text variant="body" color="secondary">{detail}</Text>
73
+
74
+ {hasOthers ? (
75
+ <>
76
+ <Text variant="sm" weight="semibold">Switch to another branch to keep working</Text>
77
+ <ScrollView style={{ maxHeight: 280 }} contentContainerStyle={{ gap: t.space(2) }}>
78
+ {branches.map((b) => (
79
+ <Button
80
+ key={b.id}
81
+ title={b.name}
82
+ fullWidth
83
+ onPress={() => onSwitch(b)}
84
+ testID={`branch-deleted-switch-${b.id}`}
85
+ />
86
+ ))}
87
+ </ScrollView>
88
+ </>
89
+ ) : (
90
+ <Text variant="sm" color="secondary" testID="branch-deleted-none">
91
+ {repository
92
+ ? `No other branch of ${repository} is connected to the CMS.`
93
+ : "No other branch of this repository is connected to the CMS."}
94
+ </Text>
95
+ )}
96
+
97
+ {onChooseRepository ? (
98
+ <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
99
+ <Button
100
+ title="Choose a repository"
101
+ variant={hasOthers ? "ghost" : "primary"}
102
+ size="md"
103
+ onPress={onChooseRepository}
104
+ testID="branch-deleted-choose-repository"
105
+ />
106
+ </View>
107
+ ) : null}
108
+ </View>
109
+ </View>
110
+ );
111
+ }
@@ -336,6 +336,15 @@ export type ContentBrowserProps = {
336
336
  // already separated from the frontmatter.
337
337
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
338
338
 
339
+ // Which field of an open document has keyboard focus, as the author moves
340
+ // between controls: the field's dotted path (`title`, `seo.description`,
341
+ // `blocks.0.heading`), or null when nothing in that document is focused.
342
+ // Web only — it reads the DOM's focus events. This is what lets a preview
343
+ // ring the part of the page the author is editing (§4.4 of the preview
344
+ // design), and it is separate from live collaboration: a lone author gets
345
+ // it too.
346
+ onFieldFocus?: (entryId: string, path: string | null) => void;
347
+
339
348
  // Optional per-field renderer. When it returns a node for a field, that node
340
349
  // replaces the built-in control (used to inject the markdown body editor).
341
350
  renderField?: RenderField;
@@ -782,8 +791,40 @@ type FieldControlProps = {
782
791
  // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
783
792
  // re-renders from `value` on its own.
784
793
  resetNonce?: number;
794
+ // The field's full dotted path, when the caller knows better than
795
+ // "parent path + field name" — a list item, whose control is named after
796
+ // the list but sits at `list.<index>`.
797
+ fieldPath?: string;
785
798
  };
786
799
 
800
+ // The dotted path of the field being rendered, for the controls under it:
801
+ // a group's children append their names to it, a list's items append their
802
+ // index. It exists so every control can carry its own path as a DOM
803
+ // attribute (`data-cms-field`) without any of them threading a prop through
804
+ // — the focus reporting in EntryDetail reads that attribute off whichever
805
+ // element the keyboard lands in.
806
+ //
807
+ // Deliberately separate from the collab `path` prop: that one is threaded
808
+ // only where a CRDT binding exists (top-level fields and drilled-in groups),
809
+ // whereas this is present for every field including list items, which the
810
+ // collab layer treats as one register.
811
+ const FieldPathContext = React.createContext<string>("");
812
+
813
+ function FieldControl(props: FieldControlProps) {
814
+ const prefix = React.useContext(FieldPathContext);
815
+ const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
816
+ return (
817
+ <FieldPathContext.Provider value={fullPath}>
818
+ <View
819
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
820
+ dataSet={{ cmsField: fullPath }}
821
+ >
822
+ <FieldControlInner {...props} />
823
+ </View>
824
+ </FieldPathContext.Provider>
825
+ );
826
+ }
827
+
787
828
  // The scalar type an array's `of` maps to when rendering item controls.
788
829
  function ofToType(of?: string): string {
789
830
  switch (of) {
@@ -794,7 +835,7 @@ function ofToType(of?: string): string {
794
835
  }
795
836
  }
796
837
 
797
- function FieldControl(props: FieldControlProps) {
838
+ function FieldControlInner(props: FieldControlProps) {
798
839
  const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
799
840
  const t = useTheme();
800
841
  const label = hideLabel ? "" : field.label || field.name;
@@ -1631,11 +1672,14 @@ function ListControl({
1631
1672
  ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1632
1673
  };
1633
1674
 
1675
+ // The list's own path, from the FieldControl wrapping this control; an item
1676
+ // sits at `<list>.<index>`.
1677
+ const listPath = React.useContext(FieldPathContext);
1634
1678
  return (
1635
1679
  <View style={{ gap: t.space(2) }}>
1636
1680
  {items.map((it, i) => (
1637
1681
  <ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
1638
- <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel />
1682
+ <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
1639
1683
  </ItemFrame>
1640
1684
  ))}
1641
1685
  {!readOnly && canAdd ? (
@@ -1675,15 +1719,25 @@ function MixedListControl({
1675
1719
  onChange(next);
1676
1720
  };
1677
1721
  const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
1722
+ const listPath = React.useContext(FieldPathContext);
1678
1723
 
1679
1724
  return (
1680
1725
  <View style={{ gap: t.space(2) }}>
1681
1726
  {items.map((it, i) => {
1682
1727
  const variantName = String(it?.[key] ?? "");
1683
1728
  const variant = variants.find((v) => v.name === variantName);
1729
+ const itemPath = listPath ? `${listPath}.${i}` : String(i);
1684
1730
  return (
1685
1731
  <ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
1686
- <View style={{ gap: t.space(2) }}>
1732
+ {/* The item's fields compose under `<list>.<index>`; the item
1733
+ itself carries that path so focusing its variant picker
1734
+ reports the item rather than the whole list. */}
1735
+ <FieldPathContext.Provider value={itemPath}>
1736
+ <View
1737
+ style={{ gap: t.space(2) }}
1738
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1739
+ dataSet={{ cmsField: itemPath }}
1740
+ >
1687
1741
  <SelectControl
1688
1742
  value={variantName}
1689
1743
  options={variants.map((v) => v.name)}
@@ -1701,6 +1755,7 @@ function MixedListControl({
1701
1755
  />
1702
1756
  ) : null}
1703
1757
  </View>
1758
+ </FieldPathContext.Provider>
1704
1759
  </ItemFrame>
1705
1760
  );
1706
1761
  })}
@@ -1745,7 +1800,13 @@ function BodyField({
1745
1800
  readOnly,
1746
1801
  });
1747
1802
  return (
1748
- <View style={{ gap: t.space(2), flex: 1 }}>
1803
+ <View
1804
+ style={{ gap: t.space(2), flex: 1 }}
1805
+ // The schema-less body is the document's one field; a preview rings
1806
+ // it under the same name the schema form would give it.
1807
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1808
+ dataSet={{ cmsField: "body" }}
1809
+ >
1749
1810
  <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
1750
1811
  <Text variant="label" color="tertiary">Body</Text>
1751
1812
  <Text variant="monoSm" color="tertiary">Markdown</Text>
@@ -2597,6 +2658,7 @@ function EntryDetail({
2597
2658
  entry,
2598
2659
  documentActions,
2599
2660
  onEntryDraft,
2661
+ onFieldFocus,
2600
2662
  renderField,
2601
2663
  onSaveEntry,
2602
2664
  readOnly = false,
@@ -2619,6 +2681,8 @@ function EntryDetail({
2619
2681
  documentActions?: (entry: CmsEntry) => React.ReactNode;
2620
2682
  // Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
2621
2683
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
2684
+ // The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
2685
+ onFieldFocus?: (entryId: string, path: string | null) => void;
2622
2686
  renderField?: RenderField;
2623
2687
  onSaveEntry?: SaveEntry;
2624
2688
  readOnly?: boolean;
@@ -2972,8 +3036,54 @@ function EntryDetail({
2972
3036
  // The field list + nested value object for the active frame.
2973
3037
  const frame = resolveFrame(editFields, values, groupPath);
2974
3038
 
3039
+ // Which field has the keyboard. One pair of DOM focus listeners on the
3040
+ // column rather than an onFocus on every control: the controls are many
3041
+ // (inputs, selects, the ProseMirror body, plugin fields) and every one of
3042
+ // them renders inside the FieldControl wrapper that carries the path as
3043
+ // `data-cms-field` — so the element the focus landed in is enough.
3044
+ //
3045
+ // focusout fires before the next focusin, so a blur is reported a tick
3046
+ // late and cancelled if focus went straight to another field: moving
3047
+ // between two controls reads as one change, not a flicker through null.
3048
+ const focusRoot = useRef<View>(null);
3049
+ const fieldFocus = useRef(onFieldFocus);
3050
+ fieldFocus.current = onFieldFocus;
3051
+ const entryId = entry.id;
3052
+ useEffect(() => {
3053
+ if (Platform.OS !== "web") return;
3054
+ const node = focusRoot.current as unknown as HTMLElement | null;
3055
+ if (!node || typeof node.addEventListener !== "function") return;
3056
+ let pending: ReturnType<typeof setTimeout> | null = null;
3057
+ let last: string | null = null;
3058
+ const report = (path: string | null) => {
3059
+ if (path === last) return;
3060
+ last = path;
3061
+ fieldFocus.current?.(entryId, path);
3062
+ };
3063
+ const onFocusIn = (ev: Event) => {
3064
+ if (pending) { clearTimeout(pending); pending = null; }
3065
+ const target = ev.target as Element | null;
3066
+ const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
3067
+ report(el?.getAttribute("data-cms-field") || null);
3068
+ };
3069
+ const onFocusOut = () => {
3070
+ if (pending) clearTimeout(pending);
3071
+ pending = setTimeout(() => { pending = null; report(null); }, 0);
3072
+ };
3073
+ node.addEventListener("focusin", onFocusIn);
3074
+ node.addEventListener("focusout", onFocusOut);
3075
+ return () => {
3076
+ node.removeEventListener("focusin", onFocusIn);
3077
+ node.removeEventListener("focusout", onFocusOut);
3078
+ if (pending) clearTimeout(pending);
3079
+ // The column is going away with the field still focused: nothing in
3080
+ // this document has focus any more.
3081
+ if (last !== null) fieldFocus.current?.(entryId, null);
3082
+ };
3083
+ }, [entryId]);
3084
+
2975
3085
  return (
2976
- <View style={{ flex: 1 }}>
3086
+ <View style={{ flex: 1 }} ref={focusRoot}>
2977
3087
  {/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
2978
3088
  dropdown it hosts) above the content pane so the menu receives clicks. */}
2979
3089
  <View
@@ -3129,6 +3239,8 @@ function EntryDetail({
3129
3239
  <Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
3130
3240
  </Pressable>
3131
3241
  ) : null}
3242
+ {/* Drilled into a group, every field's path starts with the group's. */}
3243
+ <FieldPathContext.Provider value={groupPath.join(".")}>
3132
3244
  {frame.fields.map((f) => (
3133
3245
  <FieldControl
3134
3246
  key={f.name}
@@ -3147,6 +3259,7 @@ function EntryDetail({
3147
3259
  resetNonce={resetNonces[f.name]}
3148
3260
  />
3149
3261
  ))}
3262
+ </FieldPathContext.Provider>
3150
3263
  </ScrollView>
3151
3264
  </DocumentPathProvider>
3152
3265
  ) : (
@@ -4133,6 +4246,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4133
4246
  documentActions={props.documentActions}
4134
4247
  history={props.history}
4135
4248
  onEntryDraft={props.onEntryDraft}
4249
+ onFieldFocus={props.onFieldFocus}
4136
4250
  renderField={renderField}
4137
4251
  onSaveEntry={props.onSaveEntry}
4138
4252
  readOnly={col.readOnly}
@@ -5009,6 +5123,7 @@ function MobileBrowser(props: ContentBrowserProps) {
5009
5123
  documentActions={props.documentActions}
5010
5124
  history={props.history}
5011
5125
  onEntryDraft={props.onEntryDraft}
5126
+ onFieldFocus={props.onFieldFocus}
5012
5127
  renderField={renderField}
5013
5128
  onSaveEntry={props.onSaveEntry}
5014
5129
  readOnly={readOnly}
package/src/index.ts CHANGED
@@ -57,6 +57,8 @@ export type {
57
57
  // Change-request summary surface (developer escalation)
58
58
  export { ProtectedBranchModal } from "./components/ProtectedBranchModal";
59
59
  export type { ProtectedBranchModalProps } from "./components/ProtectedBranchModal";
60
+ export { BranchDeletedModal } from "./components/BranchDeletedModal";
61
+ export type { BranchDeletedModalProps } from "./components/BranchDeletedModal";
60
62
  export { ChangeRequestSummary } from "./components/ChangeRequestSummary";
61
63
  export type {
62
64
  ChangeRequestSummaryProps,