@olenbetong/appframe-ds 0.9.1 → 1.0.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/package.json +21 -16
  3. package/scripts/copyAssets.mjs +3 -0
  4. package/src/autocomplete/Autocomplete.css +13 -0
  5. package/src/autocomplete/Autocomplete.tsx +3 -1
  6. package/src/autocomplete/Combobox.tsx +16 -3
  7. package/src/binding/BoundTextField.tsx +9 -1
  8. package/src/filter/FieldFilterPanel.css +25 -2
  9. package/src/grid/AfGridColumnsPanel.tsx +1 -1
  10. package/src/grid/AfGridContext.tsx +1 -1
  11. package/src/grid/AfGridError.tsx +1 -1
  12. package/src/grid/AfHeaderFilterCell.tsx +1 -1
  13. package/src/grid/Toolbar.tsx +1 -1
  14. package/src/grid/editing.ts +17 -7
  15. package/src/grid/filter.ts +1 -1
  16. package/src/grid/index.css +59 -1
  17. package/src/grid/index.tsx +126 -18
  18. package/src/grid/localization.ts +1 -1
  19. package/src/grid/slots/index.tsx +27 -2
  20. package/src/grid/theme.tsx +3 -50
  21. package/src/grid/useAfColumns.tsx +103 -45
  22. package/src/grid/useAfCurrentIndex.ts +13 -5
  23. package/src/grid/useAfGridApi.ts +6 -1
  24. package/src/grid/useAfKeyBindings.ts +1 -1
  25. package/src/grid/useAfNewItemRow.ts +258 -0
  26. package/src/grid/useAfPagination.ts +2 -2
  27. package/src/grid/useAfPersistedState.ts +9 -1
  28. package/src/grid/useAfRowEditModel.ts +20 -12
  29. package/src/grid/useAfRowGrouping.ts +47 -0
  30. package/src/grid/useAfServerAggregation.ts +145 -0
  31. package/src/grid/useAfSortModel.ts +15 -3
  32. package/src/layout/AppMenuDrawer.css +70 -0
  33. package/src/layout/AppMenuDrawer.tsx +122 -0
  34. package/src/layout/index.tsx +1 -0
  35. package/src/mdi/Mdi.css +138 -0
  36. package/src/mdi/Mdi.test.tsx +87 -0
  37. package/src/mdi/Mdi.tsx +141 -0
  38. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -0
  39. package/src/mdi/MdiContext.tsx +59 -0
  40. package/src/mdi/MdiDocumentContext.tsx +60 -0
  41. package/src/mdi/MdiPanel.tsx +61 -0
  42. package/src/mdi/MdiRestore.test.tsx +82 -0
  43. package/src/mdi/MdiTab.tsx +30 -0
  44. package/src/mdi/controller.test.ts +382 -0
  45. package/src/mdi/controller.ts +656 -0
  46. package/src/mdi/dockviewHost.ts +132 -0
  47. package/src/mdi/fakeHost.ts +200 -0
  48. package/src/mdi/host.ts +44 -0
  49. package/src/mdi/ids.test.ts +20 -0
  50. package/src/mdi/ids.ts +18 -0
  51. package/src/mdi/index.ts +31 -0
  52. package/src/mdi/layoutRestore.test.ts +156 -0
  53. package/src/mdi/layoutRestore.ts +311 -0
  54. package/src/mdi/layoutSpec.test.ts +143 -0
  55. package/src/mdi/layoutSpec.ts +214 -0
  56. package/src/mdi/layoutStore.test.ts +93 -0
  57. package/src/mdi/layoutStore.ts +99 -0
  58. package/src/mdi/memorySettingsStore.ts +21 -0
  59. package/src/mdi/registry.ts +34 -0
  60. package/src/mdi/testTypes.ts +37 -0
  61. package/src/mdi/theme.ts +22 -0
  62. package/src/mdi/types.ts +214 -0
  63. package/src/page/Page.css +10 -2
  64. package/src/page/PageBlock.css +5 -0
  65. package/src/page/PageBlock.tsx +14 -0
  66. package/src/page/PageHeader.css +0 -60
  67. package/src/page/PageHeader.tsx +6 -74
  68. package/src/test/happy-dom-document-class.ts +24 -0
  69. package/src/test/setup.ts +4 -0
  70. package/src/theme/useDsColorScheme.ts +53 -0
@@ -0,0 +1,93 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { layoutSpecToLayout } from "./layoutSpec.js";
4
+ import { createLayoutStore } from "./layoutStore.js";
5
+ import { createMemorySettingsStore } from "./memorySettingsStore.js";
6
+ import { testSpec, testTypes } from "./testTypes.js";
7
+
8
+ describe("layout store", () => {
9
+ beforeEach(() => {
10
+ vi.useFakeTimers();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ it("prefixes keys with the article", () => {
18
+ let store = createLayoutStore(createMemorySettingsStore(), "my-article");
19
+ expect(store.key("dock")).toBe("my-article.dock");
20
+ });
21
+
22
+ it("prefetches layouts of the article and ignores other values", async () => {
23
+ let layout = layoutSpecToLayout(testSpec, testTypes, 1);
24
+ let settings = createMemorySettingsStore({
25
+ "my-article.dock": layout,
26
+ "my-article.junk": { hello: "world" },
27
+ "other-article.dock": layout,
28
+ });
29
+ let store = createLayoutStore(settings, "my-article");
30
+
31
+ await store.loaded;
32
+
33
+ expect(store.get("my-article.dock", 1)).toEqual(layout);
34
+ expect(store.get("my-article.junk", 1)).toBeUndefined();
35
+ expect(store.get("other-article.dock", 1)).toBeUndefined();
36
+ });
37
+
38
+ it("returns nothing when the layout version differs", async () => {
39
+ let layout = layoutSpecToLayout(testSpec, testTypes, 1);
40
+ let store = createLayoutStore(createMemorySettingsStore({ "a.dock": layout }), "a");
41
+
42
+ await store.loaded;
43
+
44
+ expect(store.get("a.dock", 2)).toBeUndefined();
45
+ expect(store.get("a.dock", "1")).toBeUndefined();
46
+ expect(store.get("a.dock", 1)).toEqual(layout);
47
+ });
48
+
49
+ it("caches immediately and writes after a delay, coalescing bursts", async () => {
50
+ let settings = createMemorySettingsStore();
51
+ let store = createLayoutStore(settings, "a");
52
+ let first = layoutSpecToLayout(testSpec, testTypes, 1);
53
+ let second = { ...first, layoutVersion: 1, documents: {} };
54
+
55
+ store.set("a.dock", first);
56
+ store.set("a.dock", second);
57
+
58
+ expect(store.get("a.dock", 1)).toBe(second);
59
+ expect(settings.data.has("a.dock")).toBe(false);
60
+
61
+ await vi.advanceTimersByTimeAsync(300);
62
+
63
+ expect(settings.data.get("a.dock")).toBe(second);
64
+ });
65
+
66
+ it("writes immediately with setNow and cancels a pending delayed write", async () => {
67
+ let settings = createMemorySettingsStore();
68
+ let store = createLayoutStore(settings, "a");
69
+ let first = layoutSpecToLayout(testSpec, testTypes, 1);
70
+ let second = { ...first, documents: {} };
71
+
72
+ store.set("a.dock", first);
73
+ await store.setNow("a.dock", second);
74
+
75
+ expect(settings.data.get("a.dock")).toBe(second);
76
+
77
+ await vi.advanceTimersByTimeAsync(300);
78
+
79
+ expect(settings.data.get("a.dock")).toBe(second);
80
+ });
81
+
82
+ it("removes from the cache and the store", async () => {
83
+ let layout = layoutSpecToLayout(testSpec, testTypes, 1);
84
+ let settings = createMemorySettingsStore({ "a.dock": layout });
85
+ let store = createLayoutStore(settings, "a");
86
+
87
+ await store.loaded;
88
+ await store.remove("a.dock");
89
+
90
+ expect(store.get("a.dock", 1)).toBeUndefined();
91
+ expect(settings.data.has("a.dock")).toBe(false);
92
+ });
93
+ });
@@ -0,0 +1,99 @@
1
+ import { createSettingsStore, type SettingsStore } from "@olenbetong/appframe-react";
2
+
3
+ import type { MdiLayout } from "./types.js";
4
+
5
+ const PERSIST_DELAY = 300;
6
+
7
+ export type MdiLayoutStore = {
8
+ /** Resolves once every layout of the current article has been read into the cache */
9
+ loaded: Promise<void>;
10
+ key(id: string): string;
11
+ /** The cached layout, or `undefined` on a miss or when the version or format does not match */
12
+ get(key: string, layoutVersion: string | number): MdiLayout | undefined;
13
+ /** Caches immediately, writes to storage after a short delay so frequent layout changes coalesce */
14
+ set(key: string, layout: MdiLayout): void;
15
+ /** Caches and writes immediately */
16
+ setNow(key: string, layout: MdiLayout): Promise<void>;
17
+ remove(key: string): Promise<void>;
18
+ };
19
+
20
+ function isMdiLayout(value: unknown): value is MdiLayout {
21
+ return (
22
+ typeof value === "object" &&
23
+ value !== null &&
24
+ (value as MdiLayout).format === 1 &&
25
+ typeof (value as MdiLayout).documents === "object" &&
26
+ typeof (value as MdiLayout).dockview === "object"
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Same pattern as the grid layouts: one IndexedDB store per user, keys prefixed with the
32
+ * article id, and every key of the article prefetched at module load so the first render
33
+ * can restore synchronously.
34
+ */
35
+ export function createLayoutStore(store: SettingsStore, article: string): MdiLayoutStore {
36
+ let cache: Record<string, MdiLayout> = {};
37
+ let timeouts: Record<string, ReturnType<typeof setTimeout>> = {};
38
+
39
+ let loaded = store
40
+ .keys()
41
+ .then((keys) =>
42
+ Promise.allSettled(
43
+ keys
44
+ .filter((key) => key.startsWith(`${article}.`))
45
+ .map((key) =>
46
+ store.getItem<MdiLayout>(key).then((layout) => {
47
+ if (isMdiLayout(layout) && !cache[key]) {
48
+ cache[key] = layout;
49
+ }
50
+ }),
51
+ ),
52
+ ),
53
+ )
54
+ .then(() => undefined);
55
+
56
+ function clearPending(key: string) {
57
+ clearTimeout(timeouts[key]);
58
+ delete timeouts[key];
59
+ }
60
+
61
+ return {
62
+ loaded,
63
+ key: (id) => `${article}.${id}`,
64
+ get(key, layoutVersion) {
65
+ let layout = cache[key];
66
+
67
+ if (isMdiLayout(layout) && layout.layoutVersion === layoutVersion) {
68
+ return layout;
69
+ }
70
+
71
+ return undefined;
72
+ },
73
+ set(key, layout) {
74
+ cache[key] = layout;
75
+ clearPending(key);
76
+ timeouts[key] = setTimeout(() => {
77
+ delete timeouts[key];
78
+ store.setItem(key, layout);
79
+ }, PERSIST_DELAY);
80
+ },
81
+ async setNow(key, layout) {
82
+ cache[key] = layout;
83
+ clearPending(key);
84
+ await store.setItem(key, layout);
85
+ },
86
+ async remove(key) {
87
+ delete cache[key];
88
+ clearPending(key);
89
+ await store.removeItem(key);
90
+ },
91
+ };
92
+ }
93
+
94
+ const article = globalThis.af?.article?.id ?? globalThis.window?.location.pathname.substring(1).split("/")[0] ?? "";
95
+
96
+ export const layoutStore = createLayoutStore(
97
+ createSettingsStore(`AfMdiLayouts.${globalThis.af?.userSession?.login ?? "anonymous"}`),
98
+ article,
99
+ );
@@ -0,0 +1,21 @@
1
+ import type { SettingsStore } from "@olenbetong/appframe-react";
2
+
3
+ /** An in-memory SettingsStore for tests */
4
+ export function createMemorySettingsStore(
5
+ initial: Record<string, unknown> = {},
6
+ ): SettingsStore & { data: Map<string, unknown> } {
7
+ let data = new Map<string, unknown>(Object.entries(initial));
8
+
9
+ return {
10
+ data,
11
+ getItem: async <T>(key: string) => (data.get(key) as T) ?? null,
12
+ setItem: async <T>(key: string, value: T) => {
13
+ data.set(key, value);
14
+ return value;
15
+ },
16
+ removeItem: async (key: string) => {
17
+ data.delete(key);
18
+ },
19
+ keys: async () => [...data.keys()],
20
+ };
21
+ }
@@ -0,0 +1,34 @@
1
+ import { panelId } from "./ids.js";
2
+ import type { MdiDocumentType, MdiDocumentTypes, MdiParams } from "./types.js";
3
+
4
+ export function resolveType(types: MdiDocumentTypes, type: string): MdiDocumentType {
5
+ if (type.includes(":")) {
6
+ throw new Error(`Mdi: document type "${type}" must not contain ":" (it separates type and key in panel ids).`);
7
+ }
8
+
9
+ let definition = types[type];
10
+
11
+ if (!definition) {
12
+ throw new Error(`Mdi: unknown document type "${type}". Register it in the \`types\` of MdiProvider.`);
13
+ }
14
+
15
+ return definition;
16
+ }
17
+
18
+ /** The panel id a document of this type/key gets. Singleton types ignore the key. */
19
+ export function resolveDocumentId(types: MdiDocumentTypes, type: string, key?: string): string {
20
+ let definition = resolveType(types, type);
21
+ return panelId(type, definition.singleton ? undefined : key);
22
+ }
23
+
24
+ export function resolveTitle(definition: MdiDocumentType, type: string, params: MdiParams, key?: string): string {
25
+ if (typeof definition.title === "function") {
26
+ return definition.title(params, key);
27
+ }
28
+
29
+ return definition.title ?? type;
30
+ }
31
+
32
+ export function resolveClosable(definition: MdiDocumentType, override?: boolean): boolean {
33
+ return override ?? definition.closable ?? true;
34
+ }
@@ -0,0 +1,37 @@
1
+ import type { MdiDocumentTypes, MdiLayoutSpec } from "./types.js";
2
+
3
+ function Placeholder() {
4
+ return null;
5
+ }
6
+
7
+ /** A registry shaped like the ElementsProjectsSetup app: two fixed panes, one keyed editor, one singleton. */
8
+ export const testTypes: MdiDocumentTypes = {
9
+ explorer: { component: Placeholder, singleton: true, closable: false, title: "Explorer" },
10
+ properties: { component: Placeholder, singleton: true, closable: false, title: "Properties" },
11
+ editor: { component: Placeholder, title: (params, key) => `Editor ${(params.name as string) ?? key}` },
12
+ settings: { component: Placeholder, singleton: true, title: "Settings" },
13
+ };
14
+
15
+ export const testSpec: MdiLayoutSpec = {
16
+ root: {
17
+ direction: "horizontal",
18
+ children: [
19
+ { tabs: [{ type: "explorer" }], hideHeader: true, locked: true, size: 1 },
20
+ {
21
+ direction: "vertical",
22
+ size: 3,
23
+ children: [
24
+ {
25
+ tabs: [
26
+ { type: "editor", key: "a", params: { name: "A" } },
27
+ { type: "editor", key: "b", params: { name: "B" } },
28
+ ],
29
+ active: "editor:b",
30
+ size: 3,
31
+ },
32
+ { tabs: [{ type: "properties" }], size: 1 },
33
+ ],
34
+ },
35
+ ],
36
+ },
37
+ };
@@ -0,0 +1,22 @@
1
+ import type { DockviewTheme } from "dockview-react";
2
+
3
+ /**
4
+ * Both themes share the `ObMdi-theme` class, whose variables resolve to
5
+ * Designsystemet tokens and therefore follow `data-color-scheme` on their own.
6
+ * `colorScheme` only tells dockview which of its own defaults to use.
7
+ */
8
+ export const mdiThemeLight: DockviewTheme = {
9
+ name: "ob-light",
10
+ className: "ObMdi-theme",
11
+ colorScheme: "light",
12
+ gap: 0,
13
+ dndOverlayMounting: "absolute",
14
+ dndPanelOverlay: "group",
15
+ dndTabIndicator: "line",
16
+ };
17
+
18
+ export const mdiThemeDark: DockviewTheme = {
19
+ ...mdiThemeLight,
20
+ name: "ob-dark",
21
+ colorScheme: "dark",
22
+ };
@@ -0,0 +1,214 @@
1
+ import type { SerializedDockview } from "dockview-react";
2
+ import type { ComponentType } from "react";
3
+
4
+ /** Parameters handed to a document component. Must be JSON-serialisable, as they are persisted with the layout. */
5
+ export type MdiParams = Record<string, unknown>;
6
+
7
+ export type MdiDocumentProps<P extends MdiParams = MdiParams> = {
8
+ /** Panel id: `type` for singleton types, `type:key` otherwise */
9
+ id: string;
10
+ type: string;
11
+ /** The document key (`key` itself is reserved by React) */
12
+ documentKey?: string;
13
+ params: P;
14
+ };
15
+
16
+ export type MdiRestoreResult<P extends MdiParams = MdiParams> = boolean | Partial<{ params: P; title: string }>;
17
+
18
+ export type MdiDocumentType<P extends MdiParams = MdiParams> = {
19
+ component: ComponentType<MdiDocumentProps<P>>;
20
+ /** Initial tab caption. Documents usually override it with `setTitle()` once their data is loaded. */
21
+ title?: string | ((params: P, key?: string) => string);
22
+ /** One instance per type; `key` is ignored. Default `false` (one instance per key). */
23
+ singleton?: boolean;
24
+ /** Default for documents of this type; can be overridden per document. Default `true`. */
25
+ closable?: boolean;
26
+ /**
27
+ * Runs for every persisted document of this type before a saved layout is restored.
28
+ * Return `false` to drop it (record deleted, no access, …), `true` to keep it, or an
29
+ * object with new `params`/`title` to keep it with updated values.
30
+ */
31
+ restore?: (doc: { key?: string; params: P }) => MdiRestoreResult<P> | Promise<MdiRestoreResult<P>>;
32
+ };
33
+
34
+ // oxlint-disable-next-line typescript/no-explicit-any -- the registry holds types with different param shapes
35
+ export type MdiDocumentTypes = Record<string, MdiDocumentType<any>>;
36
+
37
+ /* ---------------------------------------------------------------------------
38
+ * Layout spec (what apps write as `defaultLayout`)
39
+ * ------------------------------------------------------------------------- */
40
+
41
+ export type MdiDocumentSpec<P extends MdiParams = MdiParams> = {
42
+ type: string;
43
+ key?: string;
44
+ params?: P;
45
+ title?: string;
46
+ /** Overrides the type default */
47
+ closable?: boolean;
48
+ };
49
+
50
+ /**
51
+ * How a group treats drops. `true`: nothing can be dropped into the group (onto its tabs
52
+ * or its centre), so it keeps exactly the tabs the layout gives it, but documents can
53
+ * still be dropped beside it and split its space. `"no-drop-target"`: the group shows
54
+ * no drop zones at all, not even along its edges. Either way its tabs cannot be reordered,
55
+ * but they can still be dragged out; see `disableDnd` on `Mdi` for fully fixed layouts.
56
+ */
57
+ export type MdiLocked = boolean | "no-drop-target";
58
+
59
+ export type MdiGroupSpec = {
60
+ /** Tabs in this group, in order */
61
+ tabs: MdiDocumentSpec[];
62
+ /** Panel id of the active tab (`type` or `type:key`). Default: the first tab. */
63
+ active?: string;
64
+ /**
65
+ * See {@link MdiLocked}. Defaults to `true` when `hideHeader` is set: a tab dropped into
66
+ * a group without a tab bar could neither be seen nor moved back out.
67
+ */
68
+ locked?: MdiLocked;
69
+ /** Hide the tab bar, for a single fixed pane such as a tree. */
70
+ hideHeader?: boolean;
71
+ /** Relative weight within the parent split. Default `1`. */
72
+ size?: number;
73
+ };
74
+
75
+ export type MdiSplitSpec = {
76
+ /** `horizontal` lays children out side by side, `vertical` stacks them. */
77
+ direction: "horizontal" | "vertical";
78
+ children: MdiLayoutNode[];
79
+ /** Relative weight within the parent split. Default `1`. */
80
+ size?: number;
81
+ };
82
+
83
+ export type MdiLayoutNode = MdiGroupSpec | MdiSplitSpec;
84
+
85
+ export type MdiLayoutSpec = {
86
+ root: MdiLayoutNode;
87
+ };
88
+
89
+ /* ---------------------------------------------------------------------------
90
+ * Persisted layout (IndexedDB value, and what `saveLayout()` returns)
91
+ * ------------------------------------------------------------------------- */
92
+
93
+ export type MdiDocumentState = {
94
+ type: string;
95
+ key?: string;
96
+ params: MdiParams;
97
+ /** Last title shown, so the tab has a caption before the component sets its own */
98
+ title?: string;
99
+ closable: boolean;
100
+ };
101
+
102
+ export type MdiLayout = {
103
+ /** Schema version of this wrapper. Bump when `MdiLayout` itself changes shape. */
104
+ format: 1;
105
+ layoutVersion: string | number;
106
+ /** What each dockview panel is, keyed by panel id */
107
+ documents: Record<string, MdiDocumentState>;
108
+ /** dockview's own layout JSON (splits, groups, tabs, popouts) */
109
+ dockview: SerializedDockview;
110
+ };
111
+
112
+ /* ---------------------------------------------------------------------------
113
+ * Imperative API
114
+ * ------------------------------------------------------------------------- */
115
+
116
+ export type MdiDirection = "left" | "right" | "above" | "below";
117
+
118
+ export type MdiPosition =
119
+ | { referencePanel: string; direction: MdiDirection | "within"; index?: number }
120
+ | { direction: MdiDirection };
121
+
122
+ export type MdiOpenOptions<P extends MdiParams = MdiParams> = {
123
+ type: string;
124
+ key?: string;
125
+ params?: P;
126
+ title?: string;
127
+ closable?: boolean;
128
+ /** Where to put a new panel. Default: in the active group. Ignored when the document is already open. */
129
+ position?: MdiPosition;
130
+ /** Open without activating. Default `false`. */
131
+ inactive?: boolean;
132
+ };
133
+
134
+ export type MdiDocumentInfo = MdiDocumentState & {
135
+ id: string;
136
+ dirty: boolean;
137
+ isActive: boolean;
138
+ };
139
+
140
+ export type MdiCloseOptions = {
141
+ /** Skip the closable check, the `onClosing` guard and the dirty prompt */
142
+ force?: boolean;
143
+ };
144
+
145
+ export type MdiConfirmCloseResult = "save" | "discard" | "cancel";
146
+
147
+ export type MdiEvents = {
148
+ open: MdiDocumentInfo;
149
+ close: MdiDocumentInfo;
150
+ activeChange: MdiDocumentInfo | undefined;
151
+ titleChange: { id: string; title: string };
152
+ dirtyChange: { id: string; dirty: boolean };
153
+ layoutChange: undefined;
154
+ };
155
+
156
+ export type MdiApi = {
157
+ /**
158
+ * Opens a document, or activates it if it is already open. An open document gets the
159
+ * new `params` (its component re-renders). Returns the panel id.
160
+ */
161
+ openDocument<P extends MdiParams = MdiParams>(options: MdiOpenOptions<P>): string;
162
+ activate(id: string): void;
163
+ /** Runs the close guards and the dirty prompt. Resolves `true` when the document was closed. */
164
+ close(id: string, options?: MdiCloseOptions): Promise<boolean>;
165
+ /** Closes every document with this key, across types. Forces by default, for when the record is gone. */
166
+ closeByKey(key: string, options?: MdiCloseOptions): Promise<void>;
167
+ /** Closes all closable documents, optionally of one type. Stops at the first cancelled close and returns `false`. */
168
+ closeAll(type?: string): Promise<boolean>;
169
+ setTitle(id: string, title: string): void;
170
+ setDirty(id: string, dirty: boolean): void;
171
+ getActive(): MdiDocumentInfo | undefined;
172
+ getDocument(id: string): MdiDocumentInfo | undefined;
173
+ getDocuments(type?: string): MdiDocumentInfo[];
174
+ hasDirty(): boolean;
175
+ /** Moves the document's group to a separate browser window. Resolves `false` if the window was blocked. */
176
+ popout(id: string): Promise<boolean>;
177
+ subscribe<K extends keyof MdiEvents>(event: K, handler: (event: MdiEvents[K]) => void): () => void;
178
+ /** Writes the current layout to IndexedDB immediately and returns it */
179
+ saveLayout(): Promise<MdiLayout | undefined>;
180
+ /** Re-applies the given layout, or the persisted one, or the default */
181
+ loadLayout(layout?: MdiLayout): Promise<void>;
182
+ /** Removes the persisted layout and applies the default */
183
+ resetLayout(): Promise<void>;
184
+ getLayout(): MdiLayout | undefined;
185
+ /** The panel id for a type/key pair */
186
+ panelId(type: string, key?: string): string;
187
+ };
188
+
189
+ export type MdiDocumentOptions = {
190
+ /**
191
+ * Called when the document is dirty and the user chose "Save" in the close prompt.
192
+ * Resolve `true` when saved (the close continues) or `false` to keep the document open.
193
+ */
194
+ onSave?: () => boolean | Promise<boolean>;
195
+ /** Runs before the dirty prompt. Return `false` to veto the close. */
196
+ onClosing?: () => boolean | Promise<boolean>;
197
+ };
198
+
199
+ export type MdiDocumentHandle<P extends MdiParams = MdiParams> = {
200
+ id: string;
201
+ type: string;
202
+ key?: string;
203
+ params: P;
204
+ isActive: boolean;
205
+ dirty: boolean;
206
+ closable: boolean;
207
+ setTitle(title: string): void;
208
+ setDirty(dirty: boolean): void;
209
+ close(options?: MdiCloseOptions): Promise<boolean>;
210
+ activate(): void;
211
+ /** The window the document is rendered in (the main window or a popout). Use it as a portal container. */
212
+ getWindow(): Window;
213
+ mdi: MdiApi;
214
+ };
package/src/page/Page.css CHANGED
@@ -12,15 +12,23 @@
12
12
  display: flex;
13
13
  flex-direction: column;
14
14
  box-sizing: border-box;
15
+ /* Containing block for the app-menu drawer, so the drawer is confined to the page
16
+ rather than the viewport. That is what keeps it clear of a site toolbar rendered
17
+ above the app, without needing to know the toolbar's height. */
18
+ position: relative;
15
19
  }
16
20
 
17
21
  .ObPage-document {
18
22
  min-height: 100%;
19
23
  }
20
24
 
25
+ /*
26
+ * Fills the element the app is mounted in, not the viewport. A site template may render
27
+ * chrome above #root (ob.2026.application renders a toolbar), in which case 100vh is taller
28
+ * than the space available and the bottom of the page is cut off.
29
+ */
21
30
  .ObPage-app {
22
- height: 100vh;
23
- height: 100dvh;
31
+ height: 100%;
24
32
  overflow: hidden;
25
33
  }
26
34
 
@@ -24,6 +24,11 @@
24
24
  max-width: 90rem;
25
25
  }
26
26
 
27
+ /* Against the leading edge instead of centred in the space left over. */
28
+ .ObPageBlock-alignStart {
29
+ margin-inline-start: 0;
30
+ }
31
+
27
32
  .ObPageBlock-gutters {
28
33
  --ob-page-gutter: 1rem;
29
34
 
@@ -8,6 +8,18 @@ export const pageBlockWidths = ["text", "md", "lg", "xl", "2xl"] as const;
8
8
  export type PageBlockWidth = (typeof pageBlockWidths)[number];
9
9
 
10
10
  export type PageBlockProps = React.HTMLAttributes<HTMLElement> & {
11
+ /**
12
+ * Where a width-constrained block sits in the space available to it.
13
+ *
14
+ * `center` is the MUI `Container` behaviour. `start` keeps the content against the leading
15
+ * edge, which is what the old `PageContainer` did (it forced `margin-left: 0`) and what an app
16
+ * with a docked navigation drawer usually wants — centering leaves the content floating away
17
+ * from the menu it belongs to.
18
+ *
19
+ * Has no effect without a `width`, since the block already fills its parent.
20
+ * @default "center"
21
+ */
22
+ align?: "center" | "start";
11
23
  /**
12
24
  * Overrides the rendered element (e.g. `"main"`, `"header"`, `"footer"`)
13
25
  * @default "div"
@@ -51,6 +63,7 @@ export type PageBlockProps = React.HTMLAttributes<HTMLElement> & {
51
63
  * `text`, `md` → `md`, `lg` → `lg`, `xl` → `xl`, `false` → no `width`).
52
64
  */
53
65
  export function PageBlock({
66
+ align,
54
67
  as: Component = "div",
55
68
  children,
56
69
  className,
@@ -65,6 +78,7 @@ export function PageBlock({
65
78
  className={clsx(
66
79
  "ObPageBlock-root",
67
80
  width && `ObPageBlock-${width}`,
81
+ align === "start" && "ObPageBlock-alignStart",
68
82
  gutters && "ObPageBlock-gutters",
69
83
  grow && "ObPageBlock-grow",
70
84
  overflow && `ObPageBlock-overflow${overflow === "auto" ? "Auto" : "Hidden"}`,
@@ -80,68 +80,8 @@
80
80
  display: none;
81
81
  }
82
82
 
83
- /* --- drawer --- */
84
-
85
- @media screen {
86
- .ObPageHeader-drawer {
87
- position: fixed;
88
- top: 0;
89
- left: 0;
90
- bottom: 0;
91
- width: var(--ob-page-drawer-width, 20rem);
92
- max-width: 90%;
93
- box-sizing: border-box;
94
- background-color: var(--ds-color-neutral-background-default, #fff);
95
- color: var(--ds-color-neutral-text-default, #1e2b3c);
96
- transform: translateX(-100%);
97
- transition: transform 225ms cubic-bezier(0, 0, 0.2, 1);
98
- overflow: hidden;
99
- display: flex;
100
- flex-direction: column;
101
- }
102
-
103
- .ObPageHeader-drawer > .MenuItems-root {
104
- flex: 1 1 auto;
105
- min-height: 0;
106
- }
107
-
108
- .ObPageHeader-drawer.ObPageHeader-drawerOpen {
109
- transform: translateX(0);
110
- }
111
-
112
- .ObPageHeader-drawerDocked {
113
- border-right: 1px solid var(--ds-color-neutral-border-subtle, #dfdfdf);
114
- z-index: 1100;
115
- }
116
-
117
- .ObPageHeader-drawerOverlayMode {
118
- z-index: 1200;
119
- box-shadow:
120
- rgb(0 0 0 / 20%) 0px 8px 10px -5px,
121
- rgb(0 0 0 / 14%) 0px 16px 24px 2px,
122
- rgb(0 0 0 / 12%) 0px 6px 30px 5px;
123
- }
124
-
125
- .ObPageHeader-drawerOverlay {
126
- position: fixed;
127
- inset: 0;
128
- background-color: rgb(0 0 0 / 50%);
129
- z-index: 1199;
130
- opacity: 0;
131
- pointer-events: none;
132
- transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1);
133
- }
134
-
135
- .ObPageHeader-drawerOverlay.ObPageHeader-drawerOpen {
136
- opacity: 1;
137
- pointer-events: auto;
138
- }
139
- }
140
-
141
83
  @media print {
142
84
  .ObPageHeader-appbar .ObPageHeader-bar,
143
- .ObPageHeader-drawer,
144
- .ObPageHeader-drawerOverlay,
145
85
  .ObPageHeader-toggle {
146
86
  display: none !important;
147
87
  }