@entifix/react-controls 0.19.0 → 0.20.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 (203) hide show
  1. package/README.md +8 -0
  2. package/dist/i18n/catalog/en.d.ts.map +1 -1
  3. package/dist/i18n/catalog/en.js +55 -9
  4. package/dist/i18n/catalog/en.js.map +1 -1
  5. package/dist/i18n/catalog/es.d.ts +64 -5
  6. package/dist/i18n/catalog/es.d.ts.map +1 -1
  7. package/dist/i18n/catalog/es.js +61 -9
  8. package/dist/i18n/catalog/es.js.map +1 -1
  9. package/dist/i18n/catalog/index.d.ts +102 -10
  10. package/dist/i18n/catalog/index.d.ts.map +1 -1
  11. package/dist/i18n/entity-labels.d.ts +14 -0
  12. package/dist/i18n/entity-labels.d.ts.map +1 -1
  13. package/dist/i18n/entity-labels.js +15 -0
  14. package/dist/i18n/entity-labels.js.map +1 -1
  15. package/dist/i18n/i18n-context.js +2 -2
  16. package/dist/i18n/i18n-context.js.map +1 -1
  17. package/dist/index.d.ts +6 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +6 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/primitives.d.ts +0 -1
  22. package/dist/primitives.d.ts.map +1 -1
  23. package/dist/primitives.js +0 -1
  24. package/dist/primitives.js.map +1 -1
  25. package/dist/theme/index.d.ts +1 -0
  26. package/dist/theme/index.d.ts.map +1 -1
  27. package/dist/theme/index.js +1 -0
  28. package/dist/theme/index.js.map +1 -1
  29. package/dist/theme/theme-context.d.ts +15 -3
  30. package/dist/theme/theme-context.d.ts.map +1 -1
  31. package/dist/theme/theme-context.js +49 -23
  32. package/dist/theme/theme-context.js.map +1 -1
  33. package/dist/theme/theme-cookie.d.ts +9 -0
  34. package/dist/theme/theme-cookie.d.ts.map +1 -0
  35. package/dist/theme/theme-cookie.js +8 -0
  36. package/dist/theme/theme-cookie.js.map +1 -0
  37. package/dist/theme/theme-script.d.ts +8 -6
  38. package/dist/theme/theme-script.d.ts.map +1 -1
  39. package/dist/theme/theme-script.js +21 -12
  40. package/dist/theme/theme-script.js.map +1 -1
  41. package/dist/ui/actions/action-surfaces.d.ts +1 -1
  42. package/dist/ui/actions/action-surfaces.js +1 -1
  43. package/dist/ui/actions/action-surfaces.js.map +1 -1
  44. package/dist/ui/actions/error-actions.d.ts +32 -0
  45. package/dist/ui/actions/error-actions.d.ts.map +1 -0
  46. package/dist/ui/actions/error-actions.js +40 -0
  47. package/dist/ui/actions/error-actions.js.map +1 -0
  48. package/dist/ui/actions/index.d.ts +2 -0
  49. package/dist/ui/actions/index.d.ts.map +1 -1
  50. package/dist/ui/actions/index.js +2 -0
  51. package/dist/ui/actions/index.js.map +1 -1
  52. package/dist/ui/actions/shortcut.d.ts +13 -0
  53. package/dist/ui/actions/shortcut.d.ts.map +1 -0
  54. package/dist/ui/actions/shortcut.js +18 -0
  55. package/dist/ui/actions/shortcut.js.map +1 -0
  56. package/dist/ui/actions/use-verb-invoker.d.ts +50 -0
  57. package/dist/ui/actions/use-verb-invoker.d.ts.map +1 -0
  58. package/dist/ui/actions/use-verb-invoker.js +93 -0
  59. package/dist/ui/actions/use-verb-invoker.js.map +1 -0
  60. package/dist/ui/atoms/cell-value/cell-value.d.ts.map +1 -1
  61. package/dist/ui/atoms/cell-value/cell-value.js +28 -14
  62. package/dist/ui/atoms/cell-value/cell-value.js.map +1 -1
  63. package/dist/ui/atoms/field-control/field-control.d.ts +1 -0
  64. package/dist/ui/atoms/field-control/field-control.d.ts.map +1 -1
  65. package/dist/ui/atoms/field-control/field-control.js +2 -0
  66. package/dist/ui/atoms/field-control/field-control.js.map +1 -1
  67. package/dist/ui/atoms/number-input/quantity-input.d.ts +8 -1
  68. package/dist/ui/atoms/number-input/quantity-input.d.ts.map +1 -1
  69. package/dist/ui/atoms/number-input/quantity-input.js +2 -1
  70. package/dist/ui/atoms/number-input/quantity-input.js.map +1 -1
  71. package/dist/ui/atoms/table/table.d.ts +12 -3
  72. package/dist/ui/atoms/table/table.d.ts.map +1 -1
  73. package/dist/ui/atoms/table/table.js +59 -10
  74. package/dist/ui/atoms/table/table.js.map +1 -1
  75. package/dist/ui/atoms/text/text.d.ts +2 -2
  76. package/dist/ui/atoms/text/text.d.ts.map +1 -1
  77. package/dist/ui/atoms/text/text.js.map +1 -1
  78. package/dist/ui/molecules/action-bar/action-bar.d.ts +76 -0
  79. package/dist/ui/molecules/action-bar/action-bar.d.ts.map +1 -0
  80. package/dist/ui/molecules/action-bar/action-bar.js +218 -0
  81. package/dist/ui/molecules/action-bar/action-bar.js.map +1 -0
  82. package/dist/ui/molecules/action-bar/index.d.ts +2 -0
  83. package/dist/ui/molecules/action-bar/index.d.ts.map +1 -0
  84. package/dist/ui/molecules/action-bar/index.js +3 -0
  85. package/dist/ui/molecules/action-bar/index.js.map +1 -0
  86. package/dist/ui/molecules/bulk-action-bar/bulk-action-bar.d.ts.map +1 -1
  87. package/dist/ui/molecules/bulk-action-bar/bulk-action-bar.js +8 -10
  88. package/dist/ui/molecules/bulk-action-bar/bulk-action-bar.js.map +1 -1
  89. package/dist/ui/molecules/bulk-result/bulk-result.d.ts +3 -3
  90. package/dist/ui/molecules/bulk-result/bulk-result.js +4 -4
  91. package/dist/ui/molecules/bulk-result/bulk-result.js.map +1 -1
  92. package/dist/ui/molecules/confirm-dialog/confirm-dialog.d.ts +24 -9
  93. package/dist/ui/molecules/confirm-dialog/confirm-dialog.d.ts.map +1 -1
  94. package/dist/ui/molecules/confirm-dialog/confirm-dialog.js +36 -11
  95. package/dist/ui/molecules/confirm-dialog/confirm-dialog.js.map +1 -1
  96. package/dist/ui/molecules/entity-record-card/entity-record-card.d.ts +33 -20
  97. package/dist/ui/molecules/entity-record-card/entity-record-card.d.ts.map +1 -1
  98. package/dist/ui/molecules/entity-record-card/entity-record-card.js +53 -20
  99. package/dist/ui/molecules/entity-record-card/entity-record-card.js.map +1 -1
  100. package/dist/ui/molecules/filter-builder/filter-builder.d.ts +8 -2
  101. package/dist/ui/molecules/filter-builder/filter-builder.d.ts.map +1 -1
  102. package/dist/ui/molecules/filter-builder/filter-builder.js +14 -5
  103. package/dist/ui/molecules/filter-builder/filter-builder.js.map +1 -1
  104. package/dist/ui/molecules/filter-chips/filter-chips.d.ts +16 -0
  105. package/dist/ui/molecules/filter-chips/filter-chips.d.ts.map +1 -0
  106. package/dist/ui/molecules/filter-chips/filter-chips.js +73 -0
  107. package/dist/ui/molecules/filter-chips/filter-chips.js.map +1 -0
  108. package/dist/ui/molecules/filter-chips/index.d.ts +2 -0
  109. package/dist/ui/molecules/filter-chips/index.d.ts.map +1 -0
  110. package/dist/ui/molecules/filter-chips/index.js +3 -0
  111. package/dist/ui/molecules/filter-chips/index.js.map +1 -0
  112. package/dist/ui/molecules/menu/menu.d.ts +11 -1
  113. package/dist/ui/molecules/menu/menu.d.ts.map +1 -1
  114. package/dist/ui/molecules/menu/menu.js +62 -2
  115. package/dist/ui/molecules/menu/menu.js.map +1 -1
  116. package/dist/ui/molecules/pagination/pagination.d.ts +5 -0
  117. package/dist/ui/molecules/pagination/pagination.d.ts.map +1 -1
  118. package/dist/ui/molecules/pagination/pagination.js +35 -18
  119. package/dist/ui/molecules/pagination/pagination.js.map +1 -1
  120. package/dist/ui/molecules/region-state/index.d.ts +2 -0
  121. package/dist/ui/molecules/region-state/index.d.ts.map +1 -0
  122. package/dist/ui/molecules/region-state/index.js +3 -0
  123. package/dist/ui/molecules/region-state/index.js.map +1 -0
  124. package/dist/ui/molecules/region-state/region-state.d.ts +42 -0
  125. package/dist/ui/molecules/region-state/region-state.d.ts.map +1 -0
  126. package/dist/ui/molecules/region-state/region-state.js +83 -0
  127. package/dist/ui/molecules/region-state/region-state.js.map +1 -0
  128. package/dist/ui/molecules/table-toolbar/table-toolbar.d.ts +3 -1
  129. package/dist/ui/molecules/table-toolbar/table-toolbar.d.ts.map +1 -1
  130. package/dist/ui/molecules/table-toolbar/table-toolbar.js +3 -2
  131. package/dist/ui/molecules/table-toolbar/table-toolbar.js.map +1 -1
  132. package/dist/ui/molecules/toast-region/toast-region.d.ts +63 -19
  133. package/dist/ui/molecules/toast-region/toast-region.d.ts.map +1 -1
  134. package/dist/ui/molecules/toast-region/toast-region.js +129 -42
  135. package/dist/ui/molecules/toast-region/toast-region.js.map +1 -1
  136. package/dist/ui/organisms/command-palette/command-palette.d.ts.map +1 -1
  137. package/dist/ui/organisms/command-palette/command-palette.js +19 -12
  138. package/dist/ui/organisms/command-palette/command-palette.js.map +1 -1
  139. package/dist/ui/organisms/entity-detail-grid/entity-detail-grid.d.ts.map +1 -1
  140. package/dist/ui/organisms/entity-detail-grid/entity-detail-grid.js +0 -1
  141. package/dist/ui/organisms/entity-detail-grid/entity-detail-grid.js.map +1 -1
  142. package/dist/ui/organisms/entity-form/entity-form.d.ts +1 -1
  143. package/dist/ui/organisms/entity-form/entity-form.d.ts.map +1 -1
  144. package/dist/ui/organisms/entity-form/entity-form.js +322 -214
  145. package/dist/ui/organisms/entity-form/entity-form.js.map +1 -1
  146. package/dist/ui/organisms/entity-form/entity-form.types.d.ts +8 -0
  147. package/dist/ui/organisms/entity-form/entity-form.types.d.ts.map +1 -1
  148. package/dist/ui/organisms/entity-form/entity-form.types.js.map +1 -1
  149. package/dist/ui/organisms/entity-table/entity-table.d.ts +1 -1
  150. package/dist/ui/organisms/entity-table/entity-table.d.ts.map +1 -1
  151. package/dist/ui/organisms/entity-table/entity-table.js +332 -197
  152. package/dist/ui/organisms/entity-table/entity-table.js.map +1 -1
  153. package/dist/ui/organisms/entity-table/entity-table.types.d.ts +19 -4
  154. package/dist/ui/organisms/entity-table/entity-table.types.d.ts.map +1 -1
  155. package/dist/ui/organisms/entity-table/entity-table.types.js.map +1 -1
  156. package/dist/ui/organisms/entity-table/header-sort.d.ts +18 -0
  157. package/dist/ui/organisms/entity-table/header-sort.d.ts.map +1 -0
  158. package/dist/ui/organisms/entity-table/header-sort.js +54 -0
  159. package/dist/ui/organisms/entity-table/header-sort.js.map +1 -0
  160. package/dist/ui/organisms/entity-table/pivot.d.ts +25 -0
  161. package/dist/ui/organisms/entity-table/pivot.d.ts.map +1 -0
  162. package/dist/ui/organisms/entity-table/pivot.js +106 -0
  163. package/dist/ui/organisms/entity-table/pivot.js.map +1 -0
  164. package/dist/ui/organisms/entity-table/use-result-announcement.d.ts +13 -0
  165. package/dist/ui/organisms/entity-table/use-result-announcement.d.ts.map +1 -0
  166. package/dist/ui/organisms/entity-table/use-result-announcement.js +31 -0
  167. package/dist/ui/organisms/entity-table/use-result-announcement.js.map +1 -0
  168. package/dist/ui/organisms/top-bar/live-indicator.d.ts +11 -0
  169. package/dist/ui/organisms/top-bar/live-indicator.d.ts.map +1 -0
  170. package/dist/ui/organisms/top-bar/live-indicator.js +46 -0
  171. package/dist/ui/organisms/top-bar/live-indicator.js.map +1 -0
  172. package/dist/ui/organisms/top-bar/top-bar.d.ts +11 -2
  173. package/dist/ui/organisms/top-bar/top-bar.d.ts.map +1 -1
  174. package/dist/ui/organisms/top-bar/top-bar.js +14 -5
  175. package/dist/ui/organisms/top-bar/top-bar.js.map +1 -1
  176. package/dist/ui/organisms/wizard/wizard.d.ts +1 -1
  177. package/dist/ui/organisms/wizard/wizard.d.ts.map +1 -1
  178. package/dist/ui/organisms/wizard/wizard.js +7 -1
  179. package/dist/ui/organisms/wizard/wizard.js.map +1 -1
  180. package/dist/ui/organisms/wizard/wizard.types.d.ts +8 -0
  181. package/dist/ui/organisms/wizard/wizard.types.d.ts.map +1 -1
  182. package/dist/ui/organisms/wizard/wizard.types.js.map +1 -1
  183. package/dist/ui/utils/live-updates.d.ts +24 -0
  184. package/dist/ui/utils/live-updates.d.ts.map +1 -0
  185. package/dist/ui/utils/live-updates.js +20 -0
  186. package/dist/ui/utils/live-updates.js.map +1 -0
  187. package/dist/ui/utils/use-focus-on-arrival.d.ts +14 -0
  188. package/dist/ui/utils/use-focus-on-arrival.d.ts.map +1 -0
  189. package/dist/ui/utils/use-focus-on-arrival.js +42 -0
  190. package/dist/ui/utils/use-focus-on-arrival.js.map +1 -0
  191. package/dist/ui/utils/use-hotkey.d.ts +44 -25
  192. package/dist/ui/utils/use-hotkey.d.ts.map +1 -1
  193. package/dist/ui/utils/use-hotkey.js +80 -48
  194. package/dist/ui/utils/use-hotkey.js.map +1 -1
  195. package/package.json +5 -5
  196. package/dist/ui/molecules/split-button/index.d.ts +0 -2
  197. package/dist/ui/molecules/split-button/index.d.ts.map +0 -1
  198. package/dist/ui/molecules/split-button/index.js +0 -3
  199. package/dist/ui/molecules/split-button/index.js.map +0 -1
  200. package/dist/ui/molecules/split-button/split-button.d.ts +0 -21
  201. package/dist/ui/molecules/split-button/split-button.d.ts.map +0 -1
  202. package/dist/ui/molecules/split-button/split-button.js +0 -35
  203. package/dist/ui/molecules/split-button/split-button.js.map +0 -1
@@ -1,22 +1,26 @@
1
1
  'use client';
2
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
3
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { cloneEntityDraft, describeEntityColumns, readDraftString, readLocalizedDraft, readRowDrafts } from '@entifix/core';
4
- import { useId, useState } from 'react';
5
- import { useErrorMessage, useLocalizedDescriptors, useT, useTranslateKey } from '../../../i18n/index.js';
6
- import { OVERFLOW_GLYPH, renderAnchor, useCasesForSurface } from '../../actions/index.js';
4
+ import { cloneEntityDraft, describeEntityColumns, extractMetaEntity, readDraftString, readLocalizedDraft, readRowDrafts } from '@entifix/core';
5
+ import { useEffect, useId, useRef, useState } from 'react';
6
+ import { useEntityNoun, useLocalizedDescriptors, useT, useTranslateKey } from '../../../i18n/index.js';
7
+ import { renderAnchor, useCasesForSurface } from '../../actions/index.js';
8
+ import { useVerbInvoker } from '../../actions/use-verb-invoker.js';
7
9
  import { Button, button } from '../../atoms/button/index.js';
8
10
  import { CellValue } from '../../atoms/cell-value/index.js';
9
11
  import { FieldControl } from '../../atoms/field-control/index.js';
10
12
  import { LocalizedFieldControl } from '../../atoms/localized-field-control/index.js';
11
13
  import { Skeleton } from '../../atoms/skeleton/index.js';
12
14
  import { Text } from '../../atoms/text/index.js';
15
+ import { ActionBar, itemsForUseCases } from '../../molecules/action-bar/index.js';
13
16
  import { Card } from '../../molecules/card/index.js';
14
- import { ConfirmDialog } from '../../molecules/confirm-dialog/index.js';
15
17
  import { EntityLinkInput } from '../../molecules/entity-link-input/index.js';
16
18
  import { LoadingBoundary } from '../../molecules/loading-boundary/index.js';
17
- import { Menu } from '../../molecules/menu/index.js';
19
+ import { RegionState } from '../../molecules/region-state/index.js';
18
20
  import { Stack } from '../../molecules/stack/index.js';
19
21
  import { cn } from '../../utils/cn.js';
22
+ import { useLiveUpdates } from '../../utils/live-updates.js';
23
+ import { useFocusOnArrival } from '../../utils/use-focus-on-arrival.js';
20
24
  import { EntityDetailGrid } from '../entity-detail-grid/index.js';
21
25
  import { readEntityFormFields } from './entity-form-slots.js';
22
26
  import { resolveEntityFormFields } from './use-entity-form-fields.js';
@@ -59,14 +63,10 @@ import { resolveEntityFormFields } from './use-entity-form-fields.js';
59
63
  *
60
64
  * It is presentational: the draft, its errors and the save/delete actions are
61
65
  * all props, so the same form hosts on a plain route and inside a workspace tab.
62
- */ /**
63
- * How many verbs a surface shows before the rest fold into an overflow menu.
64
- *
65
- * Four, because that is roughly what fits beside a title at a laptop width
66
- * without the row wrapping — the same number Fiori's object-page header settles
67
- * on. It is a layout constant, not a policy: raising it does not grant anything.
68
- */ const VISIBLE_ACTION_LIMIT = 4;
69
- export function EntityForm({ entityConstructor, entity, values, onFieldChange, linkSources, onLinkChange, mode: modeProp, defaultMode, onModeChange, errors, formError, onSubmit, onDelete, embedded = false, isLoading = false, skeleton = true, isSaving = false, isPending = false, isDeleting = false, error, backHref, renderLink = renderAnchor, title, metadata, isMetadataLoading = false, onUseCase, onClone, children }) {
66
+ */ /** The key the built-in Delete runs under, which no declared verb can take. */ const DELETE_KEY = '$delete';
67
+ /** Save, from anywhere in the form — the caret in a field included. */ const SAVE_SHORTCUT = 'Mod+S';
68
+ /** The footer's declared primary, when it is not Save. */ const PRIMARY_SHORTCUT = 'Mod+Enter';
69
+ export function EntityForm({ entityConstructor, entity, values, onFieldChange, linkSources, onLinkChange, mode: modeProp, defaultMode, onModeChange, errors, formError, onSubmit, onDelete, deletePolicy = 'confirm', embedded = false, isLoading = false, skeleton = true, isSaving = false, isPending = false, isDeleting = false, error, onRetry, backHref, renderLink = renderAnchor, title, metadata, isMetadataLoading = false, onUseCase, onClone, children }) {
70
70
  const formId = useId();
71
71
  const slots = readEntityFormFields(children);
72
72
  // The *field* descriptors, which stay local and synchronous — a column is a
@@ -105,7 +105,12 @@ export function EntityForm({ entityConstructor, entity, values, onFieldChange, l
105
105
  };
106
106
  const t = useT();
107
107
  const translateKey = useTranslateKey();
108
- const errorMessage = useErrorMessage();
108
+ const entityNoun = useEntityNoun();
109
+ const live = useLiveUpdates();
110
+ // A form reached by a write — saved, handed off by a wizard — takes focus on
111
+ // its heading, so it never rests on `<body>` (ADR 0028).
112
+ const heading = useRef(null);
113
+ useFocusOnArrival(heading);
109
114
  const busy = isSaving || isPending || isDeleting;
110
115
  // A draft restored from storage holds relation ids and no instances, so every
111
116
  // picker is resolving its target for a moment after mount. Saving inside that
@@ -141,214 +146,285 @@ export function EntityForm({ entityConstructor, entity, values, onFieldChange, l
141
146
  const declaredFooterUseCases = useCasesForSurface('form-footer', metadata == null ? void 0 : metadata.useCases);
142
147
  const headerUseCases = onUseCase ? declaredHeaderUseCases : [];
143
148
  const footerUseCases = onUseCase ? declaredFooterUseCases : [];
144
- // A descriptor carrying `confirm` must be asked about before it fires;
145
- // `revoke-sessions` ends every session a user holds. The state holds the
146
- // confirmation itself rather than the descriptor, so the dialog cannot be
147
- // opened for a verb that never asked for one.
148
- const [pending, setPending] = useState(undefined);
149
- const invoke = (descriptor)=>{
150
- const confirm = descriptor.confirm;
151
- if (confirm) {
152
- setPending({
153
- key: descriptor.key,
154
- labelKey: descriptor.labelKey,
155
- confirm
156
- });
149
+ // Every verb on the form, and its own Delete, goes through one invoker: a
150
+ // destructive verb asks, asks for the name, or runs and offers Undo, as it
151
+ // declared — `revoke-sessions` ends every session a user holds.
152
+ const verbs = useVerbInvoker({
153
+ run: (key)=>key === DELETE_KEY ? onDelete == null ? void 0 : onDelete() : onUseCase == null ? void 0 : onUseCase(key),
154
+ busy,
155
+ served: metadata == null ? void 0 : metadata.useCases
156
+ });
157
+ // What a confirmation names: the record as the reader knows it — its first
158
+ // filled-in field — or, for a record with nothing filled in, the entity.
159
+ const metaEntity = extractMetaEntity(entityConstructor);
160
+ const named = fields.find((field)=>field.type !== 'id' && readDraftString(draft, field.name) !== '');
161
+ const recordLabel = named === undefined ? entityNoun(metaEntity) : readDraftString(draft, named.name);
162
+ const invoke = (descriptor)=>verbs.invoke({
163
+ descriptor,
164
+ target: undefined,
165
+ object: recordLabel,
166
+ count: 1
167
+ });
168
+ const requestDelete = ()=>{
169
+ // Undo is the host's to offer: only it can put a deleted record back.
170
+ if (deletePolicy === 'undo') {
171
+ onDelete == null ? void 0 : onDelete();
157
172
  return;
158
173
  }
159
- onUseCase == null ? void 0 : onUseCase(descriptor.key);
160
- };
161
- const useCaseButton = (descriptor)=>{
162
- var _descriptor_confirm;
163
- return /*#__PURE__*/ _jsx(Button, {
164
- type: "button",
165
- variant: ((_descriptor_confirm = descriptor.confirm) == null ? void 0 : _descriptor_confirm.tone) === 'destructive' ? 'destructive' : 'secondary',
166
- size: descriptor.placement === 'context-independent' ? 'sm' : 'md',
167
- disabled: busy,
168
- onClick: ()=>invoke(descriptor),
169
- children: translateKey(descriptor.labelKey)
170
- }, descriptor.key);
171
- };
172
- const useCaseMenuItem = (descriptor)=>{
173
- var _descriptor_confirm;
174
- return /*#__PURE__*/ _jsx(Menu.Item, {
175
- tone: ((_descriptor_confirm = descriptor.confirm) == null ? void 0 : _descriptor_confirm.tone) === 'destructive' ? 'destructive' : 'neutral',
176
- disabled: busy,
177
- onClick: ()=>invoke(descriptor),
178
- children: translateKey(descriptor.labelKey)
179
- }, descriptor.key);
174
+ invoke({
175
+ key: DELETE_KEY,
176
+ binding: 'entity',
177
+ placement: 'determining',
178
+ labelKey: 'controls:form.delete',
179
+ confirm: {
180
+ tone: 'destructive',
181
+ messageKey: 'controls:confirm.deleteMessage'
182
+ },
183
+ reversible: deletePolicy
184
+ });
180
185
  };
186
+ const headerItems = itemsForUseCases(headerUseCases, translateKey, invoke, busy);
187
+ const footerItems = itemsForUseCases(footerUseCases, translateKey, invoke, busy);
181
188
  /**
182
- * Four actions fit a row; twelve do not.
183
- *
184
- * So a surface renders its first {@link VISIBLE_ACTION_LIMIT} verbs as
185
- * buttons and folds the rest behind one overflow trigger, in **declaration
186
- * order** — the entity's author decided which verbs matter by writing them
187
- * first, and a rule that sorted by anything else (alphabetical, destructive
188
- * last) would reorder the row every time a verb was added.
189
- *
190
- * The split is only applied when there is something to fold: a fifth action
191
- * is the point at which a menu is cheaper than a wider row, and a menu
192
- * holding one entry is strictly worse than the button it replaced.
193
- */ const withOverflow = (descriptors)=>{
194
- if (descriptors.length <= VISIBLE_ACTION_LIMIT) {
195
- return descriptors.map(useCaseButton);
196
- }
197
- const visible = descriptors.slice(0, VISIBLE_ACTION_LIMIT);
198
- const folded = descriptors.slice(VISIBLE_ACTION_LIMIT);
199
- return [
200
- ...visible.map(useCaseButton),
201
- /*#__PURE__*/ _jsxs(Menu, {
202
- children: [
203
- /*#__PURE__*/ _jsx(Menu.Trigger, {
204
- "aria-label": t('form.moreActions'),
205
- children: OVERFLOW_GLYPH
206
- }),
207
- /*#__PURE__*/ _jsx(Menu.Items, {
208
- children: folded.map(useCaseMenuItem)
209
- })
210
- ]
211
- }, "overflow")
212
- ];
189
+ * A save attempt. Counted, so a refused one moves focus to the error summary
190
+ * once — not again on every keystroke that leaves an error standing.
191
+ */ const [attempt, setAttempt] = useState(0);
192
+ const save = ()=>{
193
+ setAttempt((current)=>current + 1);
194
+ onSubmit == null ? void 0 : onSubmit(draft);
213
195
  };
214
- return /*#__PURE__*/ _jsxs(Card, {
196
+ const summary = useRef(null);
197
+ const summaryId = useId();
198
+ const invalid = editing ? fields.filter((field)=>(errors == null ? void 0 : errors[field.name]) !== undefined) : [];
199
+ const focusedAttempt = useRef(0);
200
+ useEffect(()=>{
201
+ var _summary_current;
202
+ if (attempt === focusedAttempt.current || invalid.length === 0) return;
203
+ focusedAttempt.current = attempt;
204
+ (_summary_current = summary.current) == null ? void 0 : _summary_current.focus();
205
+ }, [
206
+ attempt,
207
+ invalid.length
208
+ ]);
209
+ // Save is the footer's primary unless the entity declared one of its own — a
210
+ // determining "Publish" is what that page is for, and Save steps aside.
211
+ const declaresFooterPrimary = footerItems.some((item)=>item.emphasis === 'primary');
212
+ const footerActions = [
213
+ ...may('write') ? [
214
+ {
215
+ key: 'save',
216
+ // A pending write waits on the push stream; while that is down
217
+ // it says so, rather than waiting without a word.
218
+ label: isPending ? t(live.connection === 'open' ? 'form.pending' : 'live.waiting') : isSaving ? t('form.saving') : t('form.save'),
219
+ emphasis: declaresFooterPrimary ? 'default' : 'primary',
220
+ disabled: busy || resolvingLinks,
221
+ submit: true,
222
+ shortcut: SAVE_SHORTCUT,
223
+ onSelect: save
224
+ }
225
+ ] : [],
226
+ // The page's main act answers Mod+Enter, unless it declared its own.
227
+ ...footerItems.map((item)=>item.emphasis === 'primary' && item.shortcut === undefined ? _extends({}, item, {
228
+ shortcut: PRIMARY_SHORTCUT
229
+ }) : item),
230
+ // Clone is the CRUD triple's, not a declared verb's: every entity can be
231
+ // copied, so giving it a `@useCase()` would mean declaring the same verb on
232
+ // all 28 of them. It needs `write`, because what it produces is a create.
233
+ ...onClone && entity && may('write') ? [
234
+ {
235
+ key: 'clone',
236
+ label: t('form.clone'),
237
+ disabled: busy,
238
+ onSelect: ()=>onClone(cloneEntityDraft(entityConstructor, draft))
239
+ }
240
+ ] : [],
241
+ ...onDelete && may('delete') ? [
242
+ {
243
+ key: 'delete',
244
+ label: isDeleting ? t('form.deleting') : t('form.delete'),
245
+ destructive: true,
246
+ disabled: busy,
247
+ onSelect: requestDelete
248
+ }
249
+ ] : []
250
+ ];
251
+ // A real form while editing, so Enter in a field saves; an embedded one
252
+ // belongs to its host's flow, and a caller who may not write has no Save.
253
+ const asForm = editing && !embedded && may('write');
254
+ const rows = fields.map((field)=>/*#__PURE__*/ _jsx(FieldRow, {
255
+ field: field,
256
+ entity: entity,
257
+ draft: draft,
258
+ value: readDraftString(draft, field.name),
259
+ editing: editing,
260
+ error: editing ? errors == null ? void 0 : errors[field.name] : undefined,
261
+ setField: setField,
262
+ linkSource: linkSources == null ? void 0 : linkSources[field.name],
263
+ onLinkChange: onLinkChange,
264
+ id: `${formId}-${field.name}`
265
+ }, field.name));
266
+ const body = /*#__PURE__*/ _jsxs(Stack, {
267
+ gap: "s",
215
268
  children: [
216
- /*#__PURE__*/ _jsxs(Stack, {
217
- gap: "s",
269
+ !embedded && /*#__PURE__*/ _jsxs(Stack, {
270
+ direction: "row",
271
+ gap: "xs",
272
+ align: "center",
218
273
  children: [
219
- !embedded && /*#__PURE__*/ _jsxs(Stack, {
220
- direction: "row",
221
- gap: "xs",
222
- align: "center",
223
- children: [
224
- /*#__PURE__*/ _jsx(Text, {
225
- as: "h2",
226
- step: 1,
227
- weight: "semibold",
228
- children: title != null ? title : entity || isLoading ? t('form.details') : t('form.new')
229
- }),
230
- modeProp === undefined && entity && /*#__PURE__*/ _jsx(Button, {
231
- type: "button",
232
- variant: "ghost",
233
- size: "sm",
234
- onClick: toggleMode,
235
- children: editing ? t('form.view') : t('form.edit')
236
- }),
237
- /*#__PURE__*/ _jsx(LoadingBoundary, {
238
- isLoading: isMetadataLoading,
239
- lines: 0,
240
- children: /*#__PURE__*/ _jsxs(_Fragment, {
241
- children: [
242
- withOverflow(headerUseCases),
243
- slots.headerActions
244
- ]
245
- })
246
- })
247
- ]
274
+ /*#__PURE__*/ _jsx(Text, {
275
+ as: "h2",
276
+ ref: heading,
277
+ tabIndex: -1,
278
+ step: 1,
279
+ weight: "semibold",
280
+ className: "focus-ring",
281
+ children: title != null ? title : entity || isLoading ? t('form.details') : t('form.new')
248
282
  }),
249
- /*#__PURE__*/ _jsx("span", {
250
- role: "status",
251
- "aria-live": "polite",
252
- className: "sr-only",
253
- children: isLoading ? t('form.loading') : ''
283
+ modeProp === undefined && entity && /*#__PURE__*/ _jsx(Button, {
284
+ type: "button",
285
+ variant: "ghost",
286
+ size: "sm",
287
+ onClick: toggleMode,
288
+ children: editing ? t('form.view') : t('form.edit')
254
289
  }),
255
- error && /*#__PURE__*/ _jsx("p", {
256
- role: "alert",
257
- "data-testid": "entity-form-error",
258
- className: "rounded-sm border border-danger bg-danger-subtle px-s py-2xs text-step-sm text-danger",
259
- children: errorMessage(error)
260
- }),
261
- showSkeleton ? customSkeleton != null ? customSkeleton : fields.map((field)=>/*#__PURE__*/ _jsxs(Stack, {
262
- gap: "3xs",
290
+ /*#__PURE__*/ _jsx(LoadingBoundary, {
291
+ isLoading: isMetadataLoading,
292
+ lines: 0,
293
+ children: /*#__PURE__*/ _jsxs(_Fragment, {
263
294
  children: [
264
- /*#__PURE__*/ _jsx(Skeleton, {
265
- shape: "line",
266
- className: "w-24"
295
+ headerItems.length > 0 && /*#__PURE__*/ _jsx(ActionBar, {
296
+ surface: "form-header",
297
+ items: headerItems
267
298
  }),
268
- /*#__PURE__*/ _jsx(Skeleton, {
269
- className: "h-9 w-full"
270
- })
299
+ slots.headerActions
271
300
  ]
272
- }, field.name)) : fields.map((field)=>/*#__PURE__*/ _jsx(FieldRow, {
273
- field: field,
274
- entity: entity,
275
- draft: draft,
276
- value: readDraftString(draft, field.name),
277
- editing: editing,
278
- error: editing ? errors == null ? void 0 : errors[field.name] : undefined,
279
- setField: setField,
280
- linkSource: linkSources == null ? void 0 : linkSources[field.name],
281
- onLinkChange: onLinkChange,
282
- id: `${formId}-${field.name}`
283
- }, field.name)),
284
- ownedCollections.map((field)=>/*#__PURE__*/ _jsx(EntityDetailGrid, {
285
- descriptor: field,
286
- rows: readRowDrafts(draft[field.name]),
287
- onRowsChange: (rows)=>setField(field.name, rows),
288
- errors: editing ? errors : undefined,
289
- editing: editing,
290
- isLoading: isLoading,
291
- skeleton: skeleton
292
- }, field.name)),
293
- editing && formError && /*#__PURE__*/ _jsx("span", {
294
- role: "alert",
295
- className: "text-step-sm text-danger",
296
- children: formError
301
+ })
302
+ })
303
+ ]
304
+ }),
305
+ /*#__PURE__*/ _jsx("span", {
306
+ role: "status",
307
+ "aria-live": "polite",
308
+ className: "sr-only",
309
+ children: isLoading ? t('form.loading') : ''
310
+ }),
311
+ /*#__PURE__*/ _jsx(RegionState, {
312
+ plural: entityNoun(metaEntity, 'plural'),
313
+ error: error,
314
+ onRetry: onRetry,
315
+ // Only a load that can be retried leaves an out-of-date record; a
316
+ // failed save leaves what the reader typed, which is current.
317
+ hasStaleData: entity !== undefined && onRetry !== undefined
318
+ }),
319
+ invalid.length > 0 && /*#__PURE__*/ _jsxs("div", {
320
+ ref: summary,
321
+ tabIndex: -1,
322
+ role: "alert",
323
+ "aria-labelledby": summaryId,
324
+ "data-testid": "entity-form-error-summary",
325
+ className: "rounded-lg border-l-4 border-danger bg-danger-subtle px-s py-xs text-step-sm text-content focus-ring",
326
+ children: [
327
+ /*#__PURE__*/ _jsx("p", {
328
+ id: summaryId,
329
+ className: "m-0 font-semibold",
330
+ children: t('form.errorSummary', {
331
+ count: invalid.length
332
+ })
297
333
  }),
298
- editing && !embedded && /*#__PURE__*/ _jsxs(Stack, {
299
- direction: "row",
300
- gap: "xs",
301
- children: [
302
- may('write') && /*#__PURE__*/ _jsx(Button, {
303
- type: "button",
304
- onClick: ()=>onSubmit == null ? void 0 : onSubmit(draft),
305
- disabled: busy || resolvingLinks,
306
- children: isPending ? t('form.pending') : isSaving ? t('form.saving') : t('form.save')
307
- }),
308
- onDelete && may('delete') && /*#__PURE__*/ _jsx(Button, {
309
- type: "button",
310
- variant: "secondary",
311
- onClick: onDelete,
312
- disabled: busy,
313
- children: isDeleting ? t('form.deleting') : t('form.delete')
314
- }),
315
- withOverflow(footerUseCases),
316
- onClone && entity && may('write') && /*#__PURE__*/ _jsx(Button, {
317
- type: "button",
318
- variant: "secondary",
319
- disabled: busy,
320
- onClick: ()=>onClone(cloneEntityDraft(entityConstructor, draft)),
321
- children: t('form.clone')
322
- }),
323
- slots.footerActions,
324
- backHref && renderLink({
325
- href: backHref,
326
- className: cn(button({
327
- variant: 'ghost'
328
- }), busy && 'pointer-events-none opacity-50'),
329
- children: t('form.back'),
330
- intent: {
331
- kind: 'back'
332
- }
333
- })
334
- ]
334
+ /*#__PURE__*/ _jsx("ul", {
335
+ className: "m-0 ps-s",
336
+ children: invalid.map((field)=>/*#__PURE__*/ _jsx("li", {
337
+ children: /*#__PURE__*/ _jsx("a", {
338
+ href: `#${formId}-${field.name}`,
339
+ // The content colour: primary on the danger surface misses
340
+ // 4.5:1.
341
+ className: "font-medium text-content underline underline-offset-2 focus-ring",
342
+ onClick: (event)=>{
343
+ var _document_getElementById;
344
+ event.preventDefault();
345
+ (_document_getElementById = document.getElementById(`${formId}-${field.name}`)) == null ? void 0 : _document_getElementById.focus();
346
+ },
347
+ children: errors == null ? void 0 : errors[field.name]
348
+ })
349
+ }, field.name))
350
+ })
351
+ ]
352
+ }),
353
+ editing && fields.some((field)=>field.required && !field.readonly) && /*#__PURE__*/ _jsx(Text, {
354
+ step: -1,
355
+ tone: "muted",
356
+ children: t('form.requiredHint')
357
+ }),
358
+ showSkeleton ? customSkeleton != null ? customSkeleton : fields.map((field)=>/*#__PURE__*/ _jsxs(Stack, {
359
+ gap: "3xs",
360
+ children: [
361
+ /*#__PURE__*/ _jsx(Skeleton, {
362
+ shape: "line",
363
+ className: "w-24"
364
+ }),
365
+ /*#__PURE__*/ _jsx(Skeleton, {
366
+ className: "h-9 w-full"
367
+ })
368
+ ]
369
+ }, field.name)) : editing ? rows : // when there is no control, and a term and its value is what
370
+ // the pair is.
371
+ rows.length > 0 && /*#__PURE__*/ _jsx("dl", {
372
+ className: "m-0 flex flex-col gap-s",
373
+ children: rows
374
+ }),
375
+ ownedCollections.map((field)=>/*#__PURE__*/ _jsx(EntityDetailGrid, {
376
+ descriptor: field,
377
+ rows: readRowDrafts(draft[field.name]),
378
+ onRowsChange: (rows)=>setField(field.name, rows),
379
+ errors: editing ? errors : undefined,
380
+ editing: editing,
381
+ isLoading: isLoading,
382
+ skeleton: skeleton
383
+ }, field.name)),
384
+ editing && formError && /*#__PURE__*/ _jsx("span", {
385
+ role: "alert",
386
+ className: "text-step-sm text-danger",
387
+ children: formError
388
+ }),
389
+ editing && !embedded && /*#__PURE__*/ _jsxs(Stack, {
390
+ direction: "row",
391
+ gap: "xs",
392
+ align: "center",
393
+ children: [
394
+ /*#__PURE__*/ _jsx(ActionBar, {
395
+ surface: "form-footer",
396
+ size: "md",
397
+ items: footerActions
335
398
  }),
336
- slots.rest
399
+ slots.footerActions,
400
+ backHref && renderLink({
401
+ href: backHref,
402
+ className: cn(button({
403
+ variant: 'ghost'
404
+ }), busy && 'pointer-events-none opacity-50'),
405
+ children: t('form.back'),
406
+ intent: {
407
+ kind: 'back'
408
+ }
409
+ })
337
410
  ]
338
411
  }),
339
- pending && /*#__PURE__*/ _jsx(ConfirmDialog, {
340
- open: true,
341
- tone: pending.confirm.tone,
342
- title: translateKey(pending.labelKey),
343
- message: translateKey(pending.confirm.messageKey),
344
- busy: busy,
345
- onCancel: ()=>setPending(undefined),
346
- onConfirm: ()=>{
347
- const key = pending.key;
348
- setPending(undefined);
349
- onUseCase == null ? void 0 : onUseCase(key);
350
- }
351
- })
412
+ slots.rest
413
+ ]
414
+ });
415
+ return /*#__PURE__*/ _jsxs(Card, {
416
+ children: [
417
+ asForm ? /*#__PURE__*/ _jsx("form", {
418
+ noValidate: true,
419
+ // Enter submits through Save, the form's default button, which is
420
+ // disabled while a save is in flight — so a busy form stays put.
421
+ onSubmit: (event)=>{
422
+ event.preventDefault();
423
+ save();
424
+ },
425
+ children: body
426
+ }) : body,
427
+ verbs.dialog
352
428
  ]
353
429
  });
354
430
  }
@@ -421,6 +497,8 @@ linkSources) {
421
497
  * The source branch sits above the `virtual || isRelation` fallback and below
422
498
  * `field.render`, which is what lets a slot still override a picked field.
423
499
  */ function FieldRow({ field, entity, draft, value, editing, error, setField, linkSource, onLinkChange, id }) {
500
+ const t = useT();
501
+ const errorId = `${id}-error`;
424
502
  const readNode = field.readRender ? field.readRender(entity) : /*#__PURE__*/ _jsx(CellValue, {
425
503
  value: entity ? entity[field.name] : undefined,
426
504
  descriptor: field,
@@ -475,21 +553,51 @@ linkSources) {
475
553
  value: value,
476
554
  onChange: (next)=>setField(field.name, next),
477
555
  id: id,
478
- record: draft
556
+ record: draft,
557
+ "aria-invalid": error !== undefined || undefined,
558
+ "aria-describedby": error === undefined ? undefined : errorId,
559
+ "aria-required": field.required || undefined
479
560
  });
480
561
  }
481
- const ownsLabel = editing && field.type === 'boolean' && !field.render && !field.virtual;
482
- return /*#__PURE__*/ _jsxs(Stack, {
483
- gap: "3xs",
562
+ if (!editing) return /*#__PURE__*/ _jsxs("div", {
563
+ className: "flex flex-col gap-3xs",
484
564
  children: [
485
- !ownsLabel && /*#__PURE__*/ _jsx("label", {
486
- htmlFor: id,
565
+ /*#__PURE__*/ _jsx("dt", {
487
566
  className: "text-step-sm text-content-muted",
488
567
  children: field.label
489
568
  }),
569
+ /*#__PURE__*/ _jsx("dd", {
570
+ className: "m-0",
571
+ children: control
572
+ })
573
+ ]
574
+ });
575
+ const ownsLabel = field.type === 'boolean' && !field.render && !field.virtual;
576
+ // Marked before anyone submits, not discovered after.
577
+ const marked = field.required && !field.readonly;
578
+ return /*#__PURE__*/ _jsxs(Stack, {
579
+ gap: "3xs",
580
+ children: [
581
+ !ownsLabel && // The marker sits beside the label, not in it: the name stays the
582
+ // field's own, and `aria-required` is what says it to a reader.
583
+ /*#__PURE__*/ _jsxs("span", {
584
+ className: "flex items-baseline gap-3xs",
585
+ children: [
586
+ /*#__PURE__*/ _jsx("label", {
587
+ htmlFor: id,
588
+ className: "text-step-sm text-content-muted",
589
+ children: field.label
590
+ }),
591
+ marked && /*#__PURE__*/ _jsx("span", {
592
+ "aria-hidden": "true",
593
+ className: "text-step-sm text-danger",
594
+ children: t('form.requiredMarker')
595
+ })
596
+ ]
597
+ }),
490
598
  control,
491
599
  error && /*#__PURE__*/ _jsx("span", {
492
- role: "alert",
600
+ id: errorId,
493
601
  className: "text-step-sm text-danger",
494
602
  children: error
495
603
  })