@object-ui/react 17.6.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +3766 -0
  2. package/README.md +294 -38
  3. package/dist/SchemaRenderer.d.ts +38 -7
  4. package/dist/SchemaRenderer.js +1218 -87
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/DiscussionContext.d.ts +12 -0
  7. package/dist/context/DiscussionContext.js +2 -0
  8. package/dist/context/DrillNavigationContext.d.ts +13 -0
  9. package/dist/context/InlineEditContext.d.ts +81 -1
  10. package/dist/context/InlineEditContext.js +8 -2
  11. package/dist/context/RecordContext.d.ts +48 -5
  12. package/dist/context/RecordContext.js +10 -1
  13. package/dist/context/RelatedRecordActionsContext.d.ts +5 -1
  14. package/dist/context/SchemaRendererContext.d.ts +54 -2
  15. package/dist/context/SchemaRendererContext.js +38 -5
  16. package/dist/context/ThemeContext.js +1 -1
  17. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  18. package/dist/context/actionRunnerTranslator.js +37 -0
  19. package/dist/element-data-source/ElementDataSourceGate.d.ts +88 -1
  20. package/dist/element-data-source/ElementDataSourceGate.js +326 -13
  21. package/dist/hooks/index.d.ts +3 -1
  22. package/dist/hooks/index.js +10 -1
  23. package/dist/hooks/useActionEngine.js +46 -3
  24. package/dist/hooks/useActionRunner.js +4 -0
  25. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  26. package/dist/hooks/useActionTextLocalizer.js +33 -8
  27. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  28. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  29. package/dist/hooks/useDensityMode.d.ts +6 -2
  30. package/dist/hooks/useDensityMode.js +6 -2
  31. package/dist/hooks/useDiscovery.d.ts +1 -10
  32. package/dist/hooks/useETagCache.js +25 -4
  33. package/dist/hooks/useElementDataSource.d.ts +15 -2
  34. package/dist/hooks/useElementDataSource.js +24 -7
  35. package/dist/hooks/useExpression.d.ts +52 -59
  36. package/dist/hooks/useExpression.js +58 -90
  37. package/dist/hooks/useFilterScope.d.ts +22 -2
  38. package/dist/hooks/useFilterScope.js +29 -4
  39. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  40. package/dist/hooks/useGlobalUndo.js +112 -46
  41. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  42. package/dist/hooks/useNavigationOverlay.js +119 -9
  43. package/dist/hooks/useOffline.js +65 -11
  44. package/dist/hooks/usePageAssignment.d.ts +8 -5
  45. package/dist/hooks/usePageAssignment.js +37 -16
  46. package/dist/hooks/useRecordSearch.d.ts +34 -1
  47. package/dist/hooks/useRecordSearch.js +42 -7
  48. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  49. package/dist/hooks/useResolvedFilter.js +130 -0
  50. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  51. package/dist/hooks/useSchemaPersistence.js +144 -5
  52. package/dist/hooks/useSettledSchema.d.ts +123 -0
  53. package/dist/hooks/useSettledSchema.js +186 -0
  54. package/dist/hooks/useViewData.d.ts +7 -2
  55. package/dist/index.d.ts +8 -1
  56. package/dist/index.js +45 -1
  57. package/dist/schema-input.d.ts +38 -8
  58. package/dist/schema-input.js +39 -7
  59. package/dist/utils/configBag.d.ts +68 -0
  60. package/dist/utils/configBag.js +70 -0
  61. package/dist/utils/error-message.d.ts +1 -1
  62. package/dist/utils/error-message.js +1 -1
  63. package/dist/utils/inlineAria.d.ts +61 -0
  64. package/dist/utils/inlineAria.js +70 -0
  65. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  66. package/dist/utils/nonGridRowCeiling.js +78 -0
  67. package/dist/utils/paramsBag.d.ts +28 -0
  68. package/dist/utils/paramsBag.js +146 -0
  69. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  70. package/dist/utils/propsBagDiagnostic.js +421 -0
  71. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  72. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  73. package/dist/utils/unevaluatedExpression.d.ts +42 -1
  74. package/dist/utils/unevaluatedExpression.js +74 -11
  75. package/dist/utils/visibilityDiagnostic.d.ts +271 -19
  76. package/dist/utils/visibilityDiagnostic.js +311 -44
  77. package/package.json +10 -10
  78. package/dist/hooks/useClientNotifications.d.ts +0 -47
  79. package/dist/hooks/useClientNotifications.js +0 -162
  80. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  81. package/dist/spec-bridge/SpecBridge.js +0 -56
  82. package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
  83. package/dist/spec-bridge/bridges/form-view.js +0 -170
  84. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  85. package/dist/spec-bridge/bridges/list-view.js +0 -268
  86. package/dist/spec-bridge/index.d.ts +0 -11
  87. package/dist/spec-bridge/index.js +0 -10
  88. package/dist/spec-bridge/types.d.ts +0 -55
  89. package/dist/spec-bridge/types.js +0 -8
@@ -6,12 +6,14 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { createContext, createElement, useContext, useMemo } from 'react';
9
- import { ExpressionEvaluator, evalRowPredicate, isLegacyDialectSource, warnNonCanonicalRowSpelling, } from '@object-ui/core';
9
+ import { ExpressionEvaluator, evalRowPredicate } from '@object-ui/core';
10
10
  /**
11
11
  * Global predicate scope — populated by host shells (e.g. app-shell's
12
12
  * `ExpressionProvider`) so renderers like action buttons can resolve
13
- * deployment-level flags (`features`, `user`, `app`, …) without needing
14
- * to import the host shell directly.
13
+ * deployment-level flags (`features`, `user`, …) without needing
14
+ * to import the host shell directly. (⛔ Not `app`: objectui#8155 removed that
15
+ * root from `buildExpressionScope`, so no host shell in this repo publishes
16
+ * one.)
15
17
  *
16
18
  * `useCondition` / `useExpression` merge this scope under the
17
19
  * locally-passed `context`, so per-row `record` overrides still win.
@@ -74,56 +76,49 @@ export { toPredicateInput } from '@object-ui/core';
74
76
  * Build the predicate context for a **row record** — the bag to hand
75
77
  * {@link useCondition} when the thing being gated is scoped to one record.
76
78
  *
77
- * The row is bound the THREE ways the platform's row surfaces bind it:
78
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
79
- * `data.status` (legacy). This is not three dialects; it is one rule, and it is
80
- * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
81
- * header, list rows, the row kebab and conditional formatting all evaluate
82
- * through it), restated here for the `useCondition` tier so both tiers answer
83
- * an author's `visible:` the same way.
79
+ * The row is bound ONE way: `record.status` — the canon (objectui#5330, ruled
80
+ * 2026-08-20; Phase 2 executed by `83fe6e741`). This is not a dialect choice;
81
+ * it is one rule, and it is `evalRowPredicate`'s rule
82
+ * (`core/evaluator/listConditional.ts` — the record header, list rows, the row
83
+ * kebab and conditional formatting all evaluate through it), restated here for
84
+ * the `useCondition` tier so both tiers answer an author's `visible:` the same
85
+ * way.
84
86
  *
85
- * ## Why a helper and not "just spread the row" (objectui#4075)
87
+ * ## The two retired spellings (`83fe6e741`)
86
88
  *
87
- * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
88
- * so a caller that passes the row spread flat resolves the shorthand spelling
89
- * and NOTHING else. The canonical spelling is the `record.` root — it is what
90
- * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
91
- * namespace), what `evalRowPredicate` binds, and what the server enforces
92
- * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
93
- * the evaluator throws `record is not defined`, and a fail-closed `visible`
94
- * turns that throw into "hidden". A correctly-authored predicate then deletes
95
- * its own button, indistinguishably from the gate having said no.
89
+ * Until Phase 2 this bag also carried the row spread flat (bare `status`, the
90
+ * row-action shorthand) and as `data` (legacy), and `useCondition` below warned
91
+ * once per non-canonical spelling (Phase 1, `d1ab06f0f`). Both bindings and the
92
+ * warning are gone. A bare-field or `data.*` predicate against this bag now
93
+ * faults exactly as it always did on the server (`buildScope({ record })`
94
+ * mounts exactly `['record']`: `Unknown variable: status` / `Unknown variable:
95
+ * data`), and each `useCondition` leg applies its EXISTING fault policy — the
96
+ * throwing legs (`throwOnError`: every action `visible` — `action:button`,
97
+ * `action:menu`, `action:bar`, `DeclaredActionsBar`, and since objectui#11212
98
+ * `action:group`, `action:icon` and the related-list toolbar) hide and report
99
+ * `was hidden/disabled: its predicate threw`, naming the variable; the
100
+ * non-throwing legs fail soft to `true`. The same holds for a legacy `${data.x}` / `${x}` string: one bag
101
+ * shape, both dialects. Nothing detects a retired spelling here; it is simply
102
+ * unbound. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
103
+ * canon statement, the server measurement, the layer scoping (`data` stays
104
+ * canonical one layer over, in a metadata-editing form — ADR-0089 D3) and the
105
+ * offline detector.
96
106
  *
97
- * That was live, not theoretical: every declared action on framework's
98
- * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
99
- * so the whole server-declared approval decision set was invisible wherever
100
- * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
101
- * and the four generic action renderers carried the same root-only binding
102
- * until objectui#4075.
107
+ * ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
103
108
  *
104
- * ## The canon is `record.*` (objectui#5330, ruled 2026-08-20)
109
+ * Because the rule has two halves and every predicate face has to get both:
110
+ * the SHAPE — `record` is the row's one name, so `record.viewer.can_act`, what
111
+ * every declared action on framework's `sys_approval_request` gates on
112
+ * (framework#3310 / objectstack-ai/objectstack#3424), reaches the row — and the NO-ROW case below.
113
+ * `DeclaredActionsBar` and the four generic action renderers once carried
114
+ * inline copies of this bag and drifted (objectui#4077 / #4079); one named
115
+ * helper is what keeps a fifth copy from drifting again.
105
116
  *
106
- * All three spellings are bound here, and that is unchanged — but they are NOT
107
- * peers. `record.*` is the CONTRACT; the bare shorthand and `data.*` are
108
- * tolerances in a deprecation window, kept because stored metadata carries
109
- * them, reported once by `warnNonCanonicalRowSpelling` from `useCondition`
110
- * below, and removable only after a stored-metadata survey sizes the window
111
- * (⛔ no removal before the survey — part of the same ruling).
112
- *
113
- * The canon states the SERVER's accept set, which is narrower than this bag:
114
- * measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
115
- * exactly `['record']`, so of the three spellings this helper binds, only
116
- * `record.*` evaluates server-side — a bare field faults `Unknown variable:
117
- * status` and `data.*` faults `Unknown variable: data`. This three-way bag has
118
- * no server counterpart at all, which is exactly why the warning belongs on
119
- * this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
120
- * full measurement and the layer scoping (`data` stays canonical one layer
121
- * over, in a metadata-editing form — ADR-0089 D3).
122
- *
123
- * `record` and `data` are written AFTER the spread deliberately: a row that
124
- * happens to carry a field literally named `record` or `data` must not shadow
125
- * the root every predicate is written against. Same precedence as
126
- * `evalRowPredicate`.
117
+ * Note the direction of the merge: `useCondition` evaluates on
118
+ * `new ExpressionEvaluator({ ...scope, ...context })`, so this bag's `record`
119
+ * shadows an ambient `record` a host put in the predicate scope — the row is
120
+ * the subject — while every OTHER ambient key (`features`, `user`, a host's own
121
+ * `data`) survives to the predicate as the host's own.
127
122
  *
128
123
  * This is the BINDING rule only. It deliberately does not touch the evaluation
129
124
  * entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
@@ -135,10 +130,10 @@ export { toPredicateInput } from '@object-ui/core';
135
130
  * ## No row → bind NOTHING, do not bind an empty one
136
131
  *
137
132
  * `null` / `undefined` / a non-object returns an EMPTY bag, not
138
- * `{ record: {}, data: {} }`. The difference is load-bearing: `useCondition`
139
- * merges this context OVER the ambient predicate scope, so binding an empty
140
- * record would blank out a `record` that a host had put in the scope itself —
141
- * which is exactly how `action:group`'s dropdown leaf is driven in
133
+ * `{ record: {} }`. The difference is load-bearing: `useCondition` merges this
134
+ * context OVER the ambient predicate scope, so binding an empty record would
135
+ * blank out a `record` that a host had put in the scope itself — which is
136
+ * exactly how `action:group`'s dropdown leaf is driven in
142
137
  * `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
143
138
  * host to supply the row. "This surface has no row of its own" must stay
144
139
  * distinct from "this surface's row is empty"; only the latter is entitled to
@@ -150,8 +145,7 @@ export function usePredicateRecordContext(record) {
150
145
  return useMemo(() => {
151
146
  if (record == null || typeof record !== 'object' || Array.isArray(record))
152
147
  return {};
153
- const row = record;
154
- return { ...row, record: row, data: row };
148
+ return { record: record };
155
149
  }, [record]);
156
150
  }
157
151
  /**
@@ -188,37 +182,11 @@ export function useCondition(condition, context = {}, options) {
188
182
  const scope = usePredicateScope();
189
183
  // We evaluate directly without caching the evaluator to avoid issues with context changes
190
184
  return useMemo(() => {
191
- // objectui#5330 Phase 1 — report a deprecated row-predicate spelling on
192
- // the `useCondition` tier. This is the BINDING half's evaluation entry:
193
- // `usePredicateRecordContext` sees the row but never the predicate text,
194
- // so it structurally cannot detect a spelling, and this is the first
195
- // point where the two meet.
196
- //
197
- // Two guards, both of which can only remove a report:
198
- // - the bag must carry the `usePredicateRecordContext` signature, `data`
199
- // and `record` being the SAME object by identity. A host scope that
200
- // merely happens to carry a `data` key cannot satisfy that, so a
201
- // non-row `useCondition` call is never judged as a row predicate.
202
- // - the source must be CEL. In this tier's legacy `${…}` dialect
203
- // (`'${data.status === "active"}'` — this hook's own doc example)
204
- // `data.*` is the NORMAL spelling, and reporting it would be a false
205
- // positive on every legacy predicate.
206
- const bag = context;
207
- const boundRow = bag.record != null && typeof bag.record === 'object' && bag.record === bag.data
208
- ? bag.record
209
- : undefined;
210
- if (boundRow !== undefined) {
211
- const celSource = typeof condition === 'string'
212
- ? isLegacyDialectSource(condition)
213
- ? undefined
214
- : condition
215
- : condition && typeof condition === 'object' && condition.dialect === 'cel'
216
- ? condition.source
217
- : undefined;
218
- if (typeof celSource === 'string') {
219
- warnNonCanonicalRowSpelling(celSource, boundRow, true, options?.label);
220
- }
221
- }
185
+ // No row-spelling detector on this tier (`83fe6e741` removed the
186
+ // Phase-1 warning with the bindings): a retired bare-field / `data.*`
187
+ // spelling against a `usePredicateRecordContext` bag is simply unbound
188
+ // and faults like any other unknown variable, and each leg's existing
189
+ // fault report below is what names it.
222
190
  const evaluator = new ExpressionEvaluator({ ...scope, ...context });
223
191
  if (options?.throwOnError) {
224
192
  // Fail-closed: a predicate that can't be evaluated hides/disables
@@ -256,11 +224,11 @@ export function useCondition(condition, context = {}, options) {
256
224
  * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
257
225
  * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
258
226
  * envelope is always CEL, and only a legacy-dialect string falls back to the
259
- * old engine (with a deprecation warning). The row is bound as `record.*` — the
260
- * CANON (objectui#5330) — and, for stored metadata only, as bare fields and
261
- * `data.*`, both of which now warn once; the ambient predicate scope
262
- * (`features` / `user` / …) is merged alongside so deployment-level gates keep
263
- * resolving.
227
+ * old engine (with a deprecation warning). The row is bound as `record.*` only
228
+ * — the canon (objectui#5330; the bare-field and `data.*` spellings retired in
229
+ * `83fe6e741` and fault here like any unknown variable); the ambient
230
+ * predicate scope (`features` / `user` / …) is merged alongside so
231
+ * deployment-level gates keep resolving.
264
232
  *
265
233
  * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
266
234
  * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
@@ -20,11 +20,31 @@ export declare function FilterScopeProvider({ currentUserId, currentOrgId, child
20
20
  children: ReactNode;
21
21
  }): import("react").FunctionComponentElement<import("react").ProviderProps<FilterTokenScope>>;
22
22
  /**
23
- * Read the session scope for filter placeholder resolution.
23
+ * Read the scope for filter placeholder resolution: the session scope the host
24
+ * mounted, plus the id of the record in view when there is one.
24
25
  *
25
26
  * Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
26
27
  * `@object-ui/core` — that helper expands every placeholder vocabulary in one
27
28
  * call, which is the point: resolving only some of them is the defect behind
28
- * #3574.
29
+ * objectstack-ai/objectstack#3574.
30
+ *
31
+ * ## `{record_id}` reads the MOUNTED record (objectui#7297)
32
+ *
33
+ * `recordId` comes from the nearest `RecordContextProvider` — the record a
34
+ * `type: 'record'` page is showing, the same provider whose row a component's
35
+ * `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
36
+ * parameter, not a page variable, and not a prop of `FilterScopeProvider`,
37
+ * which stays session-only. Every data node that already resolves its filter
38
+ * through this hook therefore resolves `{record_id}` on a record page without
39
+ * a line of its own, and refuses it by name everywhere else (a list view, a
40
+ * dashboard, a page that is not a record page), because there the member is
41
+ * simply absent.
42
+ *
43
+ * Outside a record context the session scope is handed back as the very
44
+ * object the provider published, so nothing changes for a surface that shows
45
+ * no record. Inside one, the combined scope is rebuilt only when the session
46
+ * scope or the record id changes. A consumer that holds a resolution compares
47
+ * the members one by one — `recordId` among them — never this object's
48
+ * identity (AGENTS.md #10).
29
49
  */
30
50
  export declare function useFilterScope(): FilterTokenScope;
@@ -6,9 +6,12 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { createContext, createElement, useContext, useMemo } from 'react';
9
+ import { useRecordContext } from '../context/RecordContext.js';
9
10
  /**
10
11
  * The session values filter placeholders resolve against — `{current_user_id}`
11
- * and `{current_org_id}` (framework #3574).
12
+ * and `{current_org_id}` (objectstack-ai/objectstack#3574). The record-scoped
13
+ * `{record_id}` is not a session value and is not provided here: see
14
+ * {@link useFilterScope}.
12
15
  *
13
16
  * ## Why a dedicated context
14
17
  *
@@ -43,13 +46,35 @@ export function FilterScopeProvider({ currentUserId, currentOrgId, children, })
43
46
  return createElement(FilterScopeContext.Provider, { value }, children);
44
47
  }
45
48
  /**
46
- * Read the session scope for filter placeholder resolution.
49
+ * Read the scope for filter placeholder resolution: the session scope the host
50
+ * mounted, plus the id of the record in view when there is one.
47
51
  *
48
52
  * Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
49
53
  * `@object-ui/core` — that helper expands every placeholder vocabulary in one
50
54
  * call, which is the point: resolving only some of them is the defect behind
51
- * #3574.
55
+ * objectstack-ai/objectstack#3574.
56
+ *
57
+ * ## `{record_id}` reads the MOUNTED record (objectui#7297)
58
+ *
59
+ * `recordId` comes from the nearest `RecordContextProvider` — the record a
60
+ * `type: 'record'` page is showing, the same provider whose row a component's
61
+ * `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
62
+ * parameter, not a page variable, and not a prop of `FilterScopeProvider`,
63
+ * which stays session-only. Every data node that already resolves its filter
64
+ * through this hook therefore resolves `{record_id}` on a record page without
65
+ * a line of its own, and refuses it by name everywhere else (a list view, a
66
+ * dashboard, a page that is not a record page), because there the member is
67
+ * simply absent.
68
+ *
69
+ * Outside a record context the session scope is handed back as the very
70
+ * object the provider published, so nothing changes for a surface that shows
71
+ * no record. Inside one, the combined scope is rebuilt only when the session
72
+ * scope or the record id changes. A consumer that holds a resolution compares
73
+ * the members one by one — `recordId` among them — never this object's
74
+ * identity (AGENTS.md #10).
52
75
  */
53
76
  export function useFilterScope() {
54
- return useContext(FilterScopeContext);
77
+ const session = useContext(FilterScopeContext);
78
+ const recordId = useRecordContext()?.recordId;
79
+ return useMemo(() => (recordId == null ? session : { ...session, recordId }), [session, recordId]);
55
80
  }
@@ -23,6 +23,16 @@ export interface UseGlobalUndoOptions {
23
23
  *
24
24
  * Provides reactive undo/redo state, executes data operations through the
25
25
  * supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
26
+ *
27
+ * Keyboard: however many instances are mounted, one `window` `keydown`
28
+ * listener serves them all and one keypress pops at most one operation. It is
29
+ * answered by the longest-mounted live instance — its `dataSource` runs the
30
+ * write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
31
+ * target is an `input`, `textarea`, `select` or contenteditable region is left
32
+ * to that field: nothing is popped and the default is not prevented.
33
+ *
34
+ * The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
35
+ * THIS instance's `dataSource` and callbacks.
26
36
  */
27
37
  export declare function useGlobalUndo(options?: UseGlobalUndoOptions): {
28
38
  undo: () => Promise<void>;
@@ -5,7 +5,7 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
8
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
9
9
  import { globalUndoManager } from '@object-ui/core';
10
10
  function getSnapshot() {
11
11
  return {
@@ -28,59 +28,125 @@ function subscribe(callback) {
28
28
  });
29
29
  }
30
30
  function getCachedSnapshot() { return cachedSnapshot; }
31
+ async function executeOp(optionsRef, op, data, mode) {
32
+ const ds = optionsRef.current.dataSource;
33
+ if (!ds)
34
+ return;
35
+ const action = mode === 'undo'
36
+ ? { create: 'delete', update: 'update', delete: 'create' }[op.type]
37
+ : op.type;
38
+ if (action === 'delete')
39
+ await ds.delete(op.objectName, op.recordId);
40
+ else if (action === 'update')
41
+ await ds.update(op.objectName, op.recordId, data);
42
+ else
43
+ await ds.create(op.objectName, data);
44
+ }
45
+ async function runUndo(optionsRef) {
46
+ const op = globalUndoManager.popUndo();
47
+ if (!op)
48
+ return;
49
+ await executeOp(optionsRef, op, op.undoData, 'undo');
50
+ optionsRef.current.onUndo?.(op);
51
+ }
52
+ async function runRedo(optionsRef) {
53
+ const op = globalUndoManager.popRedo();
54
+ if (!op)
55
+ return;
56
+ await executeOp(optionsRef, op, op.redoData, 'redo');
57
+ optionsRef.current.onRedo?.(op);
58
+ }
59
+ // ── Keyboard shortcuts: ONE listener for the one shared stack (objectui#11081) ──
60
+ //
61
+ // Every instance used to add its own `window` `keydown` listener, and each of
62
+ // them popped `globalUndoManager`, so one Ctrl+Z undid one operation PER
63
+ // MOUNTED INSTANCE (the console mounts several at once). Now the instances
64
+ // register here and a single module-level listener answers each keypress,
65
+ // popping at most one operation.
66
+ //
67
+ // Which instance answers is decided once: the longest-mounted live one, the
68
+ // first entry of this insertion-ordered set. An `UndoableOperation` carries
69
+ // nothing that names the instance whose runner pushed it, so "the owner of the
70
+ // operation" is not readable here. The oldest live instance is also exactly
71
+ // the one that answered a keypress before this change whenever a single
72
+ // operation was stacked (its listener was first on `window` and popped it; the
73
+ // rest found the stack empty), so the one-operation keypress behaves as it
74
+ // always did. When it unmounts, the next-oldest takes over.
75
+ const keyboardInstances = new Set();
76
+ /** A field whose own Ctrl+Z (the browser's text undo) must not be taken over. */
77
+ function isEditableTarget(target) {
78
+ const el = target;
79
+ if (!el || typeof el.tagName !== 'string')
80
+ return false;
81
+ if (el.isContentEditable)
82
+ return true;
83
+ const tag = el.tagName.toUpperCase();
84
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
85
+ }
86
+ function onUndoShortcut(e) {
87
+ if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
88
+ return;
89
+ // Inside a text field Ctrl+Z is the field's own undo: no pop, no preventDefault.
90
+ if (isEditableTarget(e.target))
91
+ return;
92
+ const owner = keyboardInstances.values().next().value;
93
+ if (!owner)
94
+ return;
95
+ e.preventDefault();
96
+ if (e.shiftKey) {
97
+ void runRedo(owner);
98
+ }
99
+ else {
100
+ void runUndo(owner);
101
+ }
102
+ }
103
+ function registerKeyboardInstance(optionsRef) {
104
+ keyboardInstances.add(optionsRef);
105
+ if (keyboardInstances.size === 1)
106
+ window.addEventListener('keydown', onUndoShortcut);
107
+ return () => {
108
+ keyboardInstances.delete(optionsRef);
109
+ if (keyboardInstances.size === 0)
110
+ window.removeEventListener('keydown', onUndoShortcut);
111
+ };
112
+ }
31
113
  /**
32
114
  * React hook that wraps the global UndoManager for use in console components.
33
115
  *
34
116
  * Provides reactive undo/redo state, executes data operations through the
35
117
  * supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
118
+ *
119
+ * Keyboard: however many instances are mounted, one `window` `keydown`
120
+ * listener serves them all and one keypress pops at most one operation. It is
121
+ * answered by the longest-mounted live instance — its `dataSource` runs the
122
+ * write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
123
+ * target is an `input`, `textarea`, `select` or contenteditable region is left
124
+ * to that field: nothing is popped and the default is not prevented.
125
+ *
126
+ * The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
127
+ * THIS instance's `dataSource` and callbacks.
36
128
  */
37
129
  export function useGlobalUndo(options = {}) {
38
130
  const optionsRef = useRef(options);
39
- optionsRef.current = options;
131
+ // Refreshed in the MUTATION phase, never in the render body. Every caller
132
+ // passes an inline object literal with inline `onUndo` / `onRedo` closures,
133
+ // so `options` is a new object on every render; `undo` / `redo` and the
134
+ // keyboard registration hold the ref (whose identity React does keep) and
135
+ // still reach the newest callbacks. Writing it during render published the
136
+ // options of renders React discards or replays.
137
+ // `useInsertionEffect` runs ahead of every layout effect, ref attachment and
138
+ // paint in the commit, so the only window this moves is the render phase
139
+ // itself — where `undo` / `redo`, being async data mutations, are not
140
+ // callable. `useEffectEvent` would be idiomatic but is React 19.2+, and this
141
+ // package's peer range starts at React 18.
142
+ useInsertionEffect(() => {
143
+ optionsRef.current = options;
144
+ });
40
145
  const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot);
41
- const executeOp = useCallback(async (op, data, mode) => {
42
- const ds = optionsRef.current.dataSource;
43
- if (!ds)
44
- return;
45
- const action = mode === 'undo'
46
- ? { create: 'delete', update: 'update', delete: 'create' }[op.type]
47
- : op.type;
48
- if (action === 'delete')
49
- await ds.delete(op.objectName, op.recordId);
50
- else if (action === 'update')
51
- await ds.update(op.objectName, op.recordId, data);
52
- else
53
- await ds.create(op.objectName, data);
54
- }, []);
55
- const undo = useCallback(async () => {
56
- const op = globalUndoManager.popUndo();
57
- if (!op)
58
- return;
59
- await executeOp(op, op.undoData, 'undo');
60
- optionsRef.current.onUndo?.(op);
61
- }, [executeOp]);
62
- const redo = useCallback(async () => {
63
- const op = globalUndoManager.popRedo();
64
- if (!op)
65
- return;
66
- await executeOp(op, op.redoData, 'redo');
67
- optionsRef.current.onRedo?.(op);
68
- }, [executeOp]);
69
- // Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
70
- useEffect(() => {
71
- const handler = (e) => {
72
- if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
73
- return;
74
- e.preventDefault();
75
- if (e.shiftKey) {
76
- void redo();
77
- }
78
- else {
79
- void undo();
80
- }
81
- };
82
- window.addEventListener('keydown', handler);
83
- return () => window.removeEventListener('keydown', handler);
84
- }, [undo, redo]);
146
+ const undo = useCallback(() => runUndo(optionsRef), []);
147
+ const redo = useCallback(() => runRedo(optionsRef), []);
148
+ // Registered once per mount, keyed on the ref and on no memoised identity, so
149
+ // this instance's place in the keyboard order is its mount order.
150
+ useEffect(() => registerKeyboardInstance(optionsRef), []);
85
151
  return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]);
86
152
  }
@@ -6,6 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui';
9
+ import type { RecordNavigateAction } from '@object-ui/types';
9
10
  import type { SpecAuthoredInput } from '../spec-input.js';
10
11
  /**
11
12
  * The spec's `NavigationConfigSchema`, authoring side — by reference, with no
@@ -22,8 +23,10 @@ import type { SpecAuthoredInput } from '../spec-input.js';
22
23
  * `mode` is OPTIONAL, because the spec says so and this hook agrees.
23
24
  * `packages/spec/src/ui/view.zod.ts` declares
24
25
  * `mode: NavigationModeSchema.default('page')`, and a `.default()` lands on the
25
- * AUTHORING side as `| undefined` — so `navigation: { view: 'summary_view' }`
26
- * is legal authored metadata that lets the mode default.
26
+ * AUTHORING side as `| undefined` — so `navigation: { size: 'lg' }` is legal
27
+ * authored metadata that lets the mode default. ⛔ This sentence used to make
28
+ * the same point with a `view` member; see "The RETIRED `view` key" below for
29
+ * why no example in this file may spell that key again.
27
30
  *
28
31
  * Until objectui#4550 this alias `Omit`ted `mode` and re-added it as
29
32
  * `NonNullable<…>`, on the stated reasoning that "this hook dispatches on
@@ -51,9 +54,9 @@ import type { SpecAuthoredInput } from '../spec-input.js';
51
54
  *
52
55
  * Two per-key notes the hand copy carried, kept here because a derived alias
53
56
  * has no members to hang them on:
54
- * - `size` is the coarse overlay bucket added by #2578; `resolveOverlayWidth`
57
+ * - `size` is the coarse overlay bucket added by objectstack-ai/objectstack#2578; `resolveOverlayWidth`
55
58
  * below maps it through {@link OVERLAY_SIZE_WIDTHS}.
56
- * - `width` is DEPRECATED by #2578 in favour of `size`. It still wins when
59
+ * - `width` is DEPRECATED by objectstack-ai/objectstack#2578 in favour of `size`. It still wins when
57
60
  * present, because app-shell pre-resolves `size` into it.
58
61
  */
59
62
  export type NavigationConfig = SpecAuthoredInput<typeof NavigationConfigSchema>;
@@ -95,10 +98,50 @@ export interface UseNavigationOverlayOptions {
95
98
  navigation?: NavigationConfig;
96
99
  /** Object name — used to build default URLs for page/new_window modes */
97
100
  objectName?: string;
98
- /** External onNavigate callback (e.g., from ActionProvider or parent) */
99
- onNavigate?: (recordId: string | number, action?: string) => void;
100
- /** External onRowClick callback — if set, takes full priority */
101
- onRowClick?: (record: Record<string, unknown>) => void;
101
+ /**
102
+ * Called for page-level navigation with the record id and a
103
+ * {@link RecordNavigateAction} — a navigation-MODE token from a closed
104
+ * vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
105
+ * new browser tab.
106
+ *
107
+ * `handleClick` below is the one producer of that token, and every call it
108
+ * makes is typed against this declaration — so a third token is a compile
109
+ * error here, not a branch a host's handler silently never matches
110
+ * (objectui#9547). Supplied by the host that renders the list (a
111
+ * `schema.onNavigate`, a component prop, or the host's own router closure).
112
+ *
113
+ * When it is absent, an authored `page` click goes to the record navigator
114
+ * the host publishes on `RelatedRecordActionsContext` instead
115
+ * (objectui#11293; see the module docblock). Supplied, it wins.
116
+ */
117
+ onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
118
+ /**
119
+ * External onRowClick callback — if set, takes full priority.
120
+ *
121
+ * Declares BOTH arguments `handleClick` hands it: the record, and the
122
+ * optional modifier payload (`HandleClickModifiers`, declared just below)
123
+ * that lets a host implement Cmd/Ctrl/middle-click for itself. The second
124
+ * argument was always delivered; until objectui#9357 this line declared only
125
+ * the first and `handleClick` asserted the declaration away in order to make
126
+ * the call — so the payload was invisible on the one line a host reads, and
127
+ * every consumer that passes its own handler through copied the understated
128
+ * spelling outward.
129
+ *
130
+ * A one-parameter handler stays assignable here, and so does one that leaves
131
+ * its second parameter unannotated. The exception, and the one class that has
132
+ * to change: a handler whose second parameter is annotated NARROWER than
133
+ * `HandleClickModifiers` — React's `MouseEvent` is the shape this hits in
134
+ * practice, because the payload used to be discoverable only from the
135
+ * implementation — is refused from this card on with TS2322. The parameter is
136
+ * checked contravariantly, so the annotation has to ADMIT
137
+ * `HandleClickModifiers`. The fix is one line at that call site: annotate the
138
+ * parameter `HandleClickModifiers` (exported from this module, and from
139
+ * `@object-ui/react`), or drop the annotation and let it be inferred; either
140
+ * way the handler keeps receiving exactly what it received before. What the
141
+ * widening buys everyone else is that a caller who WANTS the payload can now
142
+ * see, from the published type, that it exists.
143
+ */
144
+ onRowClick?: (record: Record<string, unknown>, event?: HandleClickModifiers) => void;
102
145
  }
103
146
  /**
104
147
  * Optional event-like payload accepted by `handleClick`. We don't depend on
@@ -139,18 +182,40 @@ export interface NavigationOverlayState {
139
182
  width: string | number | undefined;
140
183
  /** Whether navigation is an overlay mode (drawer/modal/split/popover) */
141
184
  isOverlay: boolean;
142
- /** The target view/form name from NavigationConfig */
143
- view: string | undefined;
144
185
  }
145
186
  /**
146
187
  * Hook for NavigationConfig-driven navigation overlay.
147
188
  *
189
+ * ## What to hand `objectName`
190
+ *
191
+ * The block's RECORD SOURCE — the object the clicked rows actually came from —
192
+ * and never a bare top-level `schema.objectName` read in its place. `77cb489b4`
193
+ * published `objectName` as the THIRD RUNG of one record-source ladder (`data`,
194
+ * then `staticData`, then `objectName`), not as a parallel "page object"
195
+ * concept, so a block has exactly ONE record source. `handleClick` below builds
196
+ * the record-page URL `/{objectName}/record/{id}` out of whatever it is handed:
197
+ * a caller that hands it the top-level key while its rows came from
198
+ * `data.object` navigates to a record that the URL's own object does not
199
+ * contain (`2ce2612df`).
200
+ *
201
+ * A caller that resolves a data config reads that ladder through the ONE shared
202
+ * reader — `resolveRecordSourceObjectName` from `@object-ui/core`
203
+ * (`b041b9c0c`) — as the example does. A caller with NO data config has
204
+ * nothing above rung three, and its `schema.objectName` already IS its record
205
+ * source; that spelling is correct there and needs no conversion.
206
+ *
148
207
  * @example
149
208
  * ```tsx
209
+ * import {
210
+ * resolveRecordSourceConfig,
211
+ * resolveRecordSourceObjectName,
212
+ * } from '@object-ui/core';
213
+ *
214
+ * const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]);
150
215
  * const { handleClick, isOpen, selectedRecord, mode, close, width } =
151
216
  * useNavigationOverlay({
152
217
  * navigation: schema.navigation,
153
- * objectName: schema.objectName,
218
+ * objectName: resolveRecordSourceObjectName(schema, dataConfig),
154
219
  * onNavigate: schema.onNavigate,
155
220
  * onRowClick: props.onRowClick,
156
221
  * });