@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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|