@cosmicdrift/kumiko-renderer 0.387.0 → 0.389.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 (210) hide show
  1. package/dist/app/action-form-body.js +10 -2
  2. package/dist/app/action-form-support.d.ts +1 -0
  3. package/dist/app/action-form-support.js +1 -1
  4. package/dist/app/base-path-context.d.ts +9 -0
  5. package/dist/app/base-path-context.js +13 -0
  6. package/dist/app/base-path.d.ts +3 -0
  7. package/dist/app/base-path.js +27 -0
  8. package/dist/app/config-edit-body.d.ts +1 -1
  9. package/dist/app/config-edit-body.js +19 -6
  10. package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
  11. package/dist/app/config-edit-without-secret-fields.js +28 -0
  12. package/dist/app/entity-edit-prefill.d.ts +11 -0
  13. package/dist/app/entity-edit-prefill.js +9 -4
  14. package/dist/app/entity-list-facet-specs.d.ts +3 -0
  15. package/dist/app/entity-list-facet-specs.js +65 -0
  16. package/dist/app/entity-list-screen.js +74 -206
  17. package/dist/app/extension-sections.d.ts +5 -3
  18. package/dist/app/form-schema.js +1 -1
  19. package/dist/app/header-actions-bar.js +1 -1
  20. package/dist/app/kumiko-screen.js +6 -14
  21. package/dist/app/list-bulk-actions.d.ts +18 -0
  22. package/dist/app/list-bulk-actions.js +54 -0
  23. package/dist/app/locked-screen-header.d.ts +9 -0
  24. package/dist/app/locked-screen-header.js +33 -0
  25. package/dist/app/projection-list-body.js +51 -46
  26. package/dist/app/row-actions.d.ts +3 -0
  27. package/dist/app/row-actions.js +10 -5
  28. package/dist/app/screen-shared.js +9 -2
  29. package/dist/app/use-entity-range-facets.d.ts +24 -0
  30. package/dist/app/use-entity-range-facets.js +64 -0
  31. package/dist/app/use-projection-date-range.d.ts +12 -0
  32. package/dist/app/use-projection-date-range.js +21 -0
  33. package/dist/app/use-screen-list-rows.d.ts +37 -0
  34. package/dist/app/use-screen-list-rows.js +104 -0
  35. package/dist/app/use-screen-list-table.d.ts +24 -0
  36. package/dist/app/use-screen-list-table.js +46 -0
  37. package/dist/components/edit-mounts.d.ts +2 -2
  38. package/dist/components/embedded-list-field.js +1 -1
  39. package/dist/components/list-group-page-bridges.d.ts +3 -0
  40. package/dist/components/list-group-page-bridges.js +31 -0
  41. package/dist/components/list-state-store.d.ts +16 -0
  42. package/dist/components/list-state-store.js +31 -0
  43. package/dist/components/related-list-section.js +51 -90
  44. package/dist/components/render-edit-action-button.d.ts +1 -3
  45. package/dist/components/render-edit-action-button.js +2 -2
  46. package/dist/components/render-edit.js +11 -8
  47. package/dist/components/render-field.js +11 -7
  48. package/dist/components/render-list-toolbar-action.d.ts +27 -0
  49. package/dist/components/render-list-toolbar-action.js +40 -0
  50. package/dist/components/render-list.d.ts +13 -29
  51. package/dist/components/render-list.js +72 -67
  52. package/dist/current-user.d.ts +2 -0
  53. package/dist/data-table-aggregate-types.d.ts +47 -0
  54. package/dist/data-table-aggregate-types.js +1 -0
  55. package/dist/data-table-bulk-types.d.ts +27 -0
  56. package/dist/data-table-bulk-types.js +1 -0
  57. package/dist/data-table-facet-types.d.ts +36 -0
  58. package/dist/data-table-facet-types.js +1 -0
  59. package/dist/data-table-view-types.d.ts +73 -0
  60. package/dist/data-table-view-types.js +1 -0
  61. package/dist/flow-diagram/render.js +9 -1
  62. package/dist/headless/form/form-controller.js +8 -0
  63. package/dist/headless/form/types.d.ts +1 -0
  64. package/dist/headless/format/index.js +89 -64
  65. package/dist/headless/view-model/edit.js +5 -1
  66. package/dist/headless/view-model/list.js +184 -149
  67. package/dist/headless/view-model/types.d.ts +7 -2
  68. package/dist/hooks/list-column-layout.d.ts +19 -0
  69. package/dist/hooks/list-column-layout.js +36 -0
  70. package/dist/hooks/list-group-pages.d.ts +26 -0
  71. package/dist/hooks/list-group-pages.js +37 -0
  72. package/dist/hooks/list-reset.d.ts +8 -0
  73. package/dist/hooks/list-reset.js +15 -0
  74. package/dist/hooks/list-server-groups.d.ts +39 -0
  75. package/dist/hooks/list-server-groups.js +84 -0
  76. package/dist/hooks/list-state-schema.d.ts +28 -0
  77. package/dist/hooks/list-state-schema.js +76 -0
  78. package/dist/hooks/live-refetch-scheduler.js +4 -0
  79. package/dist/hooks/use-bulk-run.d.ts +41 -0
  80. package/dist/hooks/use-bulk-run.js +65 -0
  81. package/dist/hooks/use-group-load-state.d.ts +11 -0
  82. package/dist/hooks/use-group-load-state.js +31 -0
  83. package/dist/hooks/use-list-aggregates.d.ts +38 -0
  84. package/dist/hooks/use-list-aggregates.js +107 -0
  85. package/dist/hooks/use-list-table.d.ts +38 -0
  86. package/dist/hooks/use-list-table.js +91 -0
  87. package/dist/hooks/use-list-view-state.d.ts +43 -0
  88. package/dist/hooks/use-list-view-state.js +161 -0
  89. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  90. package/dist/hooks/use-reference-lookup.js +17 -3
  91. package/dist/hooks/use-related-list-rows.d.ts +27 -0
  92. package/dist/hooks/use-related-list-rows.js +81 -0
  93. package/dist/hooks/use-related-list-state.d.ts +15 -0
  94. package/dist/hooks/use-related-list-state.js +31 -0
  95. package/dist/hooks/use-remembered-list-params.d.ts +13 -0
  96. package/dist/hooks/use-remembered-list-params.js +53 -0
  97. package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
  98. package/dist/hooks/use-remembered-local-list-state.js +63 -0
  99. package/dist/i18n-defaults.js +59 -2
  100. package/dist/index.d.ts +4 -1
  101. package/dist/index.js +3 -0
  102. package/dist/primitives.d.ts +30 -47
  103. package/dist/web/app/create-app.d.ts +5 -0
  104. package/dist/web/app/create-app.js +15 -7
  105. package/dist/web/app/dashboard-body.js +17 -1
  106. package/dist/web/app/dashboard-panels.js +12 -2
  107. package/dist/web/app/history-leave-guard.d.ts +6 -0
  108. package/dist/web/app/history-leave-guard.js +115 -0
  109. package/dist/web/app/list-state-store-boot.d.ts +6 -0
  110. package/dist/web/app/list-state-store-boot.js +15 -0
  111. package/dist/web/app/nav.js +7 -54
  112. package/dist/web/app/session-list-state-store.d.ts +15 -0
  113. package/dist/web/app/session-list-state-store.js +117 -0
  114. package/dist/web/index.d.ts +2 -1
  115. package/dist/web/index.js +2 -1
  116. package/dist/web/layout/shell-header.js +2 -1
  117. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  118. package/dist/web/layout/uniform-bar-controls.js +4 -0
  119. package/dist/web/lib/export-xlsx-request.d.ts +11 -0
  120. package/dist/web/lib/export-xlsx-request.js +46 -0
  121. package/dist/web/lib/file-ref-download.js +4 -1
  122. package/dist/web/primitives/action-menu.js +1 -1
  123. package/dist/web/primitives/code-input.d.ts +6 -0
  124. package/dist/web/primitives/code-input.js +31 -0
  125. package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
  126. package/dist/web/primitives/data-table/actions-column.js +34 -0
  127. package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
  128. package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
  129. package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
  130. package/dist/web/primitives/data-table/card-sort-select.js +40 -0
  131. package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
  132. package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
  133. package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
  134. package/dist/web/primitives/data-table/column-picker-section.js +35 -0
  135. package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
  136. package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
  137. package/dist/web/primitives/data-table/density.d.ts +14 -0
  138. package/dist/web/primitives/data-table/density.js +20 -0
  139. package/dist/web/primitives/data-table/export-button.d.ts +5 -0
  140. package/dist/web/primitives/data-table/export-button.js +37 -0
  141. package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
  142. package/dist/web/primitives/data-table/facet-cluster.js +37 -0
  143. package/dist/web/primitives/data-table/group-band.d.ts +28 -0
  144. package/dist/web/primitives/data-table/group-band.js +53 -0
  145. package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
  146. package/dist/web/primitives/data-table/measure-value.js +28 -0
  147. package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
  148. package/dist/web/primitives/data-table/pin-section.js +20 -0
  149. package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
  150. package/dist/web/primitives/data-table/reset-all-button.js +8 -0
  151. package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
  152. package/dist/web/primitives/data-table/server-group-header.js +51 -0
  153. package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
  154. package/dist/web/primitives/data-table/sortable-header.js +41 -0
  155. package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
  156. package/dist/web/primitives/data-table/table-chrome.js +30 -0
  157. package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
  158. package/dist/web/primitives/data-table/table-footer.js +20 -0
  159. package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
  160. package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
  161. package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
  162. package/dist/web/primitives/data-table/totals-footer.js +48 -0
  163. package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
  164. package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
  165. package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
  166. package/dist/web/primitives/data-table/use-column-pins.js +117 -0
  167. package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
  168. package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
  169. package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
  170. package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
  171. package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
  172. package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
  173. package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
  174. package/dist/web/primitives/data-table/use-row-selection.js +48 -0
  175. package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
  176. package/dist/web/primitives/data-table/use-view-columns.js +17 -0
  177. package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
  178. package/dist/web/primitives/data-table/view-menu.js +45 -0
  179. package/dist/web/primitives/data-table-cell.d.ts +0 -22
  180. package/dist/web/primitives/data-table-cell.js +1 -58
  181. package/dist/web/primitives/data-table.d.ts +1 -1
  182. package/dist/web/primitives/data-table.js +125 -146
  183. package/dist/web/primitives/facet-filter.js +1 -1
  184. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  185. package/dist/web/primitives/file-thumbnail.js +5 -0
  186. package/dist/web/primitives/file-value-list.d.ts +1 -1
  187. package/dist/web/primitives/file-value-list.js +5 -5
  188. package/dist/web/primitives/input.js +6 -1
  189. package/dist/web/primitives/layout-elements.js +6 -4
  190. package/dist/web/primitives/number-range-facet.d.ts +7 -0
  191. package/dist/web/primitives/number-range-facet.js +33 -0
  192. package/dist/web/primitives/page-header.js +3 -1
  193. package/dist/web/primitives/row-actions.js +3 -3
  194. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  195. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  196. package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
  197. package/dist/web/widgets/bounded-load-cache.js +49 -21
  198. package/dist/web/widgets/choice-cards.d.ts +17 -0
  199. package/dist/web/widgets/choice-cards.js +12 -0
  200. package/dist/web/widgets/collapsible-section.js +1 -1
  201. package/dist/web/widgets/detail-list.d.ts +5 -1
  202. package/dist/web/widgets/detail-list.js +5 -1
  203. package/dist/web/widgets/index.d.ts +1 -0
  204. package/dist/web/widgets/index.js +1 -0
  205. package/dist/web/widgets/pdf-preview.js +10 -5
  206. package/dist/web/widgets/plan-card.d.ts +2 -0
  207. package/dist/web/widgets/plan-card.js +1 -1
  208. package/dist/web/widgets/upload-zone.js +13 -5
  209. package/package.json +5 -5
  210. package/src/changes.json +158 -0
@@ -0,0 +1,31 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ // One invisible bridge per requested group page: the number of open groups and
3
+ // loaded pages varies, so useQuery can't be called in a loop. The pool caps
4
+ // group page requests at four in flight.
5
+ import { useEffect, useRef } from "react";
6
+ import { useQuery } from "../hooks/use-query.js";
7
+ const GROUP_PAGE_CONCURRENCY = 4;
8
+ function GroupPageBridge({ pageKey, queryType, payload, onResult, refreshNonce, }) {
9
+ const result = useQuery(queryType, payload, {
10
+ live: true,
11
+ concurrency: GROUP_PAGE_CONCURRENCY,
12
+ });
13
+ const { data, loading, refetch } = result;
14
+ useEffect(() => {
15
+ if (data === null)
16
+ return;
17
+ onResult(pageKey, { rows: data.rows, nextCursor: data.nextCursor ?? null, loading });
18
+ }, [data, loading, pageKey, onResult]);
19
+ const seenNonce = useRef(refreshNonce);
20
+ useEffect(() => {
21
+ // skip: the mount fetch already covers the nonce the bridge started with
22
+ if (seenNonce.current === refreshNonce)
23
+ return;
24
+ seenNonce.current = refreshNonce;
25
+ void refetch();
26
+ }, [refreshNonce, refetch]);
27
+ return null;
28
+ }
29
+ export function ListGroupPageBridges({ queryType, requests, onResult, refreshNonce, }) {
30
+ return (_jsx(_Fragment, { children: requests.map((request) => (_jsx(GroupPageBridge, { pageKey: request.pageKey, queryType: queryType, payload: request.payload, onResult: onResult, refreshNonce: refreshNonce }, request.pageKey))) }));
31
+ }
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ import type { StoredListState } from "../hooks/list-state-schema.ts";
3
+ export type ListStateStore = {
4
+ read(key: string): StoredListState | undefined;
5
+ write(key: string, state: StoredListState): void;
6
+ remove(key: string): void;
7
+ clear(): void;
8
+ };
9
+ export declare function createMemoryListStateStore(): ListStateStore;
10
+ export declare function ListStateStoreProvider({ store, children, }: {
11
+ readonly store: ListStateStore;
12
+ readonly children: ReactNode;
13
+ }): ReactNode;
14
+ export declare function useListStateStore(): ListStateStore;
15
+ /** The shared in-memory default; the web boot clears it when the identity changes. */
16
+ export declare function getDefaultMemoryListStateStore(): ListStateStore;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ export function createMemoryListStateStore() {
4
+ const entries = new Map();
5
+ return {
6
+ read: (key) => entries.get(key),
7
+ write: (key, state) => {
8
+ entries.set(key, state);
9
+ },
10
+ remove: (key) => {
11
+ entries.delete(key);
12
+ },
13
+ clear: () => {
14
+ entries.clear();
15
+ },
16
+ };
17
+ }
18
+ // Module-wide so remembered list state survives route changes in the SPA
19
+ // without any provider mounted.
20
+ const defaultMemoryStore = createMemoryListStateStore();
21
+ const ListStateStoreContext = createContext(defaultMemoryStore);
22
+ export function ListStateStoreProvider({ store, children, }) {
23
+ return _jsx(ListStateStoreContext.Provider, { value: store, children: children });
24
+ }
25
+ export function useListStateStore() {
26
+ return useContext(ListStateStoreContext);
27
+ }
28
+ /** The shared in-memory default; the web boot clears it when the identity changes. */
29
+ export function getDefaultMemoryListStateStore() {
30
+ return defaultMemoryStore;
31
+ }
@@ -1,9 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { evalFieldCondition, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { useCallback, useMemo, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { useClientActionRuntime } from "../app/client-actions.js";
6
6
  import { ErrorReference } from "../app/error-reference.js";
7
+ import { useListBulk } from "../app/list-bulk-actions.js";
7
8
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
8
9
  import { useNav } from "../app/nav.js";
9
10
  import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
@@ -15,15 +16,29 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
15
16
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
16
17
  import { useUserRoles } from "../context/user-roles-context.js";
17
18
  import { rendererWarn } from "../dev-console.js";
18
- import { useQuery } from "../hooks/use-query.js";
19
+ import { useRelatedListRows } from "../hooks/use-related-list-rows.js";
20
+ import { useRelatedListState } from "../hooks/use-related-list-state.js";
19
21
  import { useOptionalLocale, useTranslation } from "../i18n.js";
20
22
  import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
21
- import { usePrimitives, } from "../primitives.js";
22
- import { sortByAccessor } from "../sort-by-accessor.js";
23
+ import { usePrimitives } from "../primitives.js";
24
+ import { ListGroupPageBridges } from "./list-group-page-bridges.js";
23
25
  import { RenderEditActionButton } from "./render-edit-action-button.js";
24
26
  import { RenderList } from "./render-list.js";
27
+ // Keyed on the type, not on key presence: most factories omit `sortable`
28
+ // from the definition, so `"sortable" in field` would drop the column flag.
29
+ const NON_SORTABLE_FIELD_TYPES = new Set([
30
+ "longText",
31
+ "multiSelect",
32
+ "embedded",
33
+ "jsonb",
34
+ "tz",
35
+ "file",
36
+ "image",
37
+ "files",
38
+ "images",
39
+ ]);
25
40
  function isSortableCapable(field) {
26
- return "sortable" in field;
41
+ return !NON_SORTABLE_FIELD_TYPES.has(field.type);
27
42
  }
28
43
  const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
29
44
  // Minimal EntityDefinition from the section's own columns — same shape as
@@ -72,36 +87,6 @@ function valueTypeField(valueType, sortable) {
72
87
  function warnRelatedListEmptyStateDrawerDropped(actionId) {
73
88
  rendererWarn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
74
89
  }
75
- function buildRowGrouping(groupBy, translate, locale) {
76
- return {
77
- keyOf: (row) => relatedListGroupKey(row.values[groupBy.field]),
78
- startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === relatedListGroupKey(groupBy.collapsedWhen),
79
- headerLabel: (key, rows) => {
80
- const labelKey = relatedListGroupHeaderLabel(groupBy, key);
81
- if (labelKey === undefined)
82
- return undefined;
83
- const dates = groupBy.dateField === undefined
84
- ? []
85
- : rows
86
- .map((row) => String(row.values[groupBy.dateField ?? ""] ?? ""))
87
- .filter((value) => value !== "")
88
- .sort();
89
- const latest = dates[dates.length - 1];
90
- return translate(labelKey, {
91
- count: rows.length,
92
- value: key,
93
- lastDate: latest === undefined
94
- ? ""
95
- : new Date(latest).toLocaleDateString(locale, {
96
- year: "numeric",
97
- month: "2-digit",
98
- day: "2-digit",
99
- timeZone: "UTC",
100
- }),
101
- });
102
- },
103
- };
104
- }
105
90
  function buildRowTone(rule) {
106
91
  if (rule === undefined)
107
92
  return undefined;
@@ -113,9 +98,6 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
113
98
  const t = useTranslation();
114
99
  const effectiveTranslate = translate ?? t;
115
100
  const locale = useOptionalLocale();
116
- const rowGrouping = useMemo(() => section.groupBy === undefined
117
- ? undefined
118
- : buildRowGrouping(section.groupBy, effectiveTranslate, locale ?? "en"), [section.groupBy, effectiveTranslate, locale]);
119
101
  const rowTone = useMemo(() => buildRowTone(section.rowTone), [section.rowTone]);
120
102
  const nav = useNav();
121
103
  const host = useReturnHost();
@@ -152,8 +134,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
152
134
  // Local state, not URL state: a section `id` is optional, so there is no
153
135
  // stable URL key to namespace against — the section's sort is local for the
154
136
  // same reason. Consequence: search/filters reset on reload.
155
- const [search, setSearch] = useState("");
156
- const [filters, setFilters] = useState({});
137
+ const { search, filters, sort, setSearch, setFilters, setSort, listTable, groupField } = useRelatedListState(section, nav.route?.screenId);
157
138
  const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
158
139
  const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
159
140
  const handleFacetOptions = useCallback((field, options) => setReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
@@ -185,39 +166,19 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
185
166
  search,
186
167
  filterPayload,
187
168
  ]);
188
- const rowsQuery = useQuery(section.query, payload);
189
- const ownRefetch = rowsQuery.refetch;
190
- const lastRefreshTokenRef = useRef(refreshToken);
191
- useEffect(() => {
192
- if (lastRefreshTokenRef.current === refreshToken)
193
- return;
194
- lastRefreshTokenRef.current = refreshToken;
195
- void ownRefetch();
196
- }, [refreshToken, ownRefetch]);
197
- const refetchSelfAndHost = useCallback(async () => {
198
- await ownRefetch();
199
- await onAfterWrite?.();
200
- }, [ownRefetch, onAfterWrite]);
201
- const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
202
- const onFilterReset = useCallback(() => setFilters({}), []);
203
- // Sorted client-side over the already-loaded rows only. The section has no
204
- // pager (one-shot fetch); when that fetch is truncated (`nextCursor` set) the
205
- // truncation banner below tells the user the sorted set is partial.
206
- const [sort, setSort] = useState(section.defaultSort ?? null);
207
- const sortAccessors = useMemo(() => {
208
- const accessors = {};
209
- for (const col of section.columns) {
210
- const field = normalizeListColumn(col).field;
211
- accessors[field] = (row) => {
212
- const value = row[field];
213
- if (value === null || value === undefined)
214
- return null;
215
- return typeof value === "number" ? value : String(value);
216
- };
217
- }
218
- return accessors;
219
- }, [section.columns]);
220
- const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors, locale), [rowsQuery.data, sort, sortAccessors, locale]);
169
+ const listRows = useRelatedListRows({
170
+ section,
171
+ payload,
172
+ groupField,
173
+ sort,
174
+ locale,
175
+ refreshToken,
176
+ onAfterWrite,
177
+ translate: effectiveTranslate,
178
+ });
179
+ const { refetchSelfAndHost, aggregates } = listRows;
180
+ const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), [setFilters]);
181
+ const onFilterReset = useCallback(() => setFilters({}), [setFilters]);
221
182
  const rowClick = section.rowClick;
222
183
  // A row-body click target comes from EITHER the legacy `rowClick` field OR
223
184
  // a `rowActions` entry marked rowClick:true — the boot-validator rejects
@@ -265,6 +226,13 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
265
226
  defaultEditRowAction,
266
227
  host,
267
228
  ]);
229
+ const bulk = useListBulk({
230
+ bulkActions: section.bulkActions,
231
+ rowActions: section.rowActions,
232
+ translate: effectiveTranslate,
233
+ dispatcher,
234
+ reload: refetchSelfAndHost,
235
+ });
268
236
  // Toolbar actions (e.g. "+ Create" above the tab table) — same schema
269
237
  // and dispatch semantics as entityList/projectionList.
270
238
  const toolbarActionButtons = useMemo(() => buildProjectionToolbarActions({
@@ -335,20 +303,11 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
335
303
  refetchSelfAndHost,
336
304
  ]);
337
305
  const emptyStateContent = section.emptyState !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: section.emptyState.title }), section.emptyState.description !== undefined && (_jsx(Text, { variant: "small", children: section.emptyState.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
338
- // A truncated fetch means `sortedRows` is a sort of a partial set, not of
339
- // the full related-row set — the client-side sort above (or even plain
340
- // unsorted display) would silently claim "these are the top N" when they
341
- // are really just "the first N in the server's own order".
342
- // `nextCursor` is exactly how the paged envelope marks that: non-null means
343
- // more rows exist server-side beyond what was fetched.
344
- // A handler that omits `nextCursor` (or reports a `total` the fetched rows
345
- // already cover) has no further rows — only a real cursor marks truncation.
346
- const truncated = rowsQuery.data !== null &&
347
- typeof rowsQuery.data.nextCursor === "string" &&
348
- (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
349
- const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
350
- count: sortedRows.length,
351
- }) })), emptyStateActionError !== null && (_jsxs(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: [emptyStateActionError.message, _jsx(ErrorReference, { requestId: emptyStateActionError.requestId })] })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
306
+ // Without a cursor-driven pager the client-side sort covers only the fetched
307
+ // rows; the banner keeps it from claiming "these are the top N".
308
+ const content = listRows.firstLoad ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : listRows.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(listRows.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [listRows.truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
309
+ count: listRows.rows.length,
310
+ }) })), emptyStateActionError !== null && (_jsxs(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: [emptyStateActionError.message, _jsx(ErrorReference, { requestId: emptyStateActionError.requestId })] })), _jsx(ListGroupPageBridges, { ...aggregates.groupPages }), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: listRows.rows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
352
311
  searchable: true,
353
312
  searchValue: search,
354
313
  onSearchChange: setSearch,
@@ -357,7 +316,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
357
316
  filterValues: filters,
358
317
  onFilterChange,
359
318
  onFilterReset,
360
- }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
319
+ }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...listTable, ...(aggregates.serverGroups !== undefined && { serverGroups: aggregates.serverGroups }), ...(aggregates.totals !== undefined && { totals: aggregates.totals }), ...(aggregates.notice !== undefined && { notice: aggregates.notice }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
361
320
  // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
362
321
  // component's own return value in the same Card frame every other tab
363
322
  // kind gets — no card here, or the two would nest. `scrollBody` caps the
@@ -369,7 +328,9 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
369
328
  // and aren't confined to a tab panel.
370
329
  const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
371
330
  if (embedded === true) {
372
- const countSuffix = rowsQuery.data !== null ? ` · ${sortedRows.length}` : "";
331
+ const countSuffix = listRows.hasData
332
+ ? ` · ${aggregates.serverGroups?.totalCount ?? listRows.rows.length}`
333
+ : "";
373
334
  return (_jsxs(_Fragment, { children: [bridges, _jsx(Card, { options: { framed: false, padded: false },
374
335
  // Trim the default header inset: the host row already pads the area.
375
336
  className: "[&>div:first-child]:px-0 [&>div:first-child]:pt-1 [&>div:first-child]:pb-2", slots: {
@@ -10,15 +10,13 @@ export declare function RenderEditActionConfirmDialog({ action, open, onOpenChan
10
10
  readonly onConfirm: () => void | Promise<void>;
11
11
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
12
12
  }): ReactNode;
13
- export declare function RenderEditActionButton({ action, iconOnly, hideIcon, compactLabel, Button, Dialog, onError, }: {
13
+ export declare function RenderEditActionButton({ action, iconOnly, hideIcon, Button, Dialog, onError, }: {
14
14
  readonly action: RenderEditAction;
15
15
  /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
16
16
  * effect when this action actually resolved an icon. */
17
17
  readonly iconOnly?: boolean;
18
18
  /** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
19
19
  readonly hideIcon?: boolean;
20
- /** Short labelled button for a phone header, where a bare icon would hide what the action does. */
21
- readonly compactLabel?: boolean;
22
20
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
23
21
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
24
22
  readonly onError: (notice: ErrorNotice | null) => void;
@@ -15,7 +15,7 @@ export function RenderEditActionConfirmDialog({ action, open, onOpenChange, onCo
15
15
  // render-list.tsx's ToolbarActionView (each RenderEditAction is
16
16
  // independently bound by the caller, there is no shared trigger pipeline
17
17
  // to hook into like the built-in onDelete/onSubmit paths have).
18
- export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, compactLabel = false, Button, Dialog, onError, }) {
18
+ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, Button, Dialog, onError, }) {
19
19
  const [busy, setBusy] = useState(false);
20
20
  const [confirmOpen, setConfirmOpen] = useState(false);
21
21
  const trigger = async () => {
@@ -33,7 +33,7 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
33
33
  };
34
34
  const variant = action.style ?? "secondary";
35
35
  const showIconOnly = iconOnly && action.icon !== undefined;
36
- return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(compactLabel && !showIconOnly && { size: "sm" }), ...(showIconOnly && {
36
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
37
37
  size: "icon",
38
38
  ariaLabel: action.label,
39
39
  title: action.label,
@@ -215,10 +215,13 @@ export function RenderEdit(props) {
215
215
  const scopeFieldNamesRef = useRef(scopeFieldNames);
216
216
  scopeFieldNamesRef.current = scopeFieldNames;
217
217
  const scopedValidate = useCallback(() => controller.validate(scopeFieldNamesRef.current), [controller]);
218
- const markSaved = useCallback(() => {
219
- controller.rebase();
220
- extensionDirtyRef.current = false;
221
- setExtensionDirty(false);
218
+ // The extension dirty flag aggregates every section; each section clears its
219
+ // own part, so a host-side markSaved must not reset it.
220
+ const markSaved = useCallback((fields) => {
221
+ if (fields === undefined)
222
+ controller.rebase();
223
+ else
224
+ controller.rebaseFields(fields);
222
225
  }, [controller]);
223
226
  // `saveDraft` (defined below) is a fresh closure every render, over that
224
227
  // render's `draftKey`/`draftId` — the ref lets the debounce timer below
@@ -1048,7 +1051,7 @@ export function RenderEdit(props) {
1048
1051
  // Title-less sections would otherwise collide on key/testId, so use the index
1049
1052
  // as a fallback.
1050
1053
  const sectionKey = section.title ?? `section-${sectionIndex}`;
1051
- const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
1054
+ const renderFieldGrid = (fields, columns, gridKey, columnsDeclared) => (_jsx(Grid, { columns: usesFieldFlow && columnsDeclared !== true ? 1 : columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
1052
1055
  layout: "row",
1053
1056
  accent: fieldAccent?.(field.field) === true,
1054
1057
  }), issues: snapshot.errors[field.field], onChange: (v) => {
@@ -1075,7 +1078,7 @@ export function RenderEdit(props) {
1075
1078
  // cannot stand in for the tabs-mode card here.
1076
1079
  if (hideSectionTitles === true) {
1077
1080
  if (section.groups !== undefined) {
1078
- const groupsGrid = (_jsx(Grid, { columns: 1, children: section.groups.map((group, groupIndex) => (_jsx(Card, { slots: { title: group.title }, testId: `section-${sectionKey}-group-${groupIndex}`, children: renderFieldGrid(group.fields, group.columns, `${sectionKey}-group-${groupIndex}-grid`) }, group.title))) }, `${sectionKey}-groups`));
1081
+ const groupsGrid = (_jsx(Grid, { columns: 1, children: section.groups.map((group, groupIndex) => (_jsx(Card, { slots: { title: group.title }, testId: `section-${sectionKey}-group-${groupIndex}`, children: renderFieldGrid(group.fields, group.columns, `${sectionKey}-group-${groupIndex}-grid`, group.columnsDeclared) }, group.title))) }, `${sectionKey}-groups`));
1079
1082
  // `actions` on a `groups` section has no group of its own to
1080
1083
  // sit in and would need an extra title-less Card around the
1081
1084
  // group cards (Card-in-Card) — the boot-validator rejects that
@@ -1092,7 +1095,7 @@ export function RenderEdit(props) {
1092
1095
  ...(section.description !== undefined && { subtitle: section.description }),
1093
1096
  ...(sectionActionsEl !== undefined && { headerActions: sectionActionsEl }),
1094
1097
  },
1095
- }), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`) }, sectionKey));
1098
+ }), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared) }, sectionKey));
1096
1099
  return wrapWizardStep(sectionKey, cardEl);
1097
1100
  }
1098
1101
  // Suppress the section header when it would just repeat the form
@@ -1109,7 +1112,7 @@ export function RenderEdit(props) {
1109
1112
  const sectionSubtitle = section.description ?? (headerFallback ? formSubtitle : undefined);
1110
1113
  const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
1111
1114
  id: formSectionDomId(sectionIndex),
1112
- }), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)] }, sectionKey));
1115
+ }), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared)] }, sectionKey));
1113
1116
  return wrapWizardStep(sectionKey, sectionEl);
1114
1117
  }), formError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1115
1118
  onReload();
@@ -16,7 +16,7 @@ import { fileIdsOf } from "../file-ids-of.js";
16
16
  import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "../headless/index.js";
17
17
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
18
18
  import { useQuery } from "../hooks/use-query.js";
19
- import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
19
+ import { isCompleteRowPage, referenceOptionSource } from "../hooks/use-reference-lookup.js";
20
20
  import { useLocale, useTranslation } from "../i18n.js";
21
21
  import { usePrimitives } from "../primitives.js";
22
22
  import { AvailabilityOptionsMultiSelect, AvailabilityOptionsSelect, staticSelectOptions, } from "./availability-options-select.js";
@@ -285,6 +285,7 @@ function inputId(field) {
285
285
  function FieldRendererOutput({ field, renderer, row, appLocale, }) {
286
286
  const { Text, JsonView, FileValueList } = usePrimitives();
287
287
  const t = useTranslation();
288
+ const valueTestId = `field-value-${field.field}`;
288
289
  const componentName = !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
289
290
  ? extensionSectionName(renderer)
290
291
  : undefined;
@@ -294,20 +295,20 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
294
295
  // itself) — applyFormatSpec's already-indented string is only the
295
296
  // fallback for primitives-providers without JsonView.
296
297
  if (renderer.format === "json" && JsonView !== undefined) {
297
- return (_jsx(JsonView, { value: field.value, indent: renderer.indent, testId: `field-value-${field.field}` }));
298
+ return _jsx(JsonView, { value: field.value, indent: renderer.indent, testId: valueTestId });
298
299
  }
299
300
  if (renderer.format === "file" && FileValueList !== undefined) {
300
301
  const fileIds = fileIdsOf(field.value);
301
302
  if (fileIds.length === 0)
302
- return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
303
- return _jsx(FileValueList, { fileIds: fileIds, testId: `field-value-${field.field}` });
303
+ return _jsx(Text, { testId: valueTestId, children: EMPTY_VALUE_PLACEHOLDER });
304
+ return (_jsx(FileValueList, { fileIds: fileIds, previewOpenByDefault: renderer.previewOpenByDefault === true, testId: valueTestId }));
304
305
  }
305
306
  // App locale as default when the FormatSpec declares none of its own —
306
307
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
307
308
  // bigInt/unit) fell back to Intl's runtime default instead of the app
308
309
  // language chosen via LocaleProvider. Prefer renderer.locale
309
310
  // when set; coalesce undefined (spread override) back to appLocale.
310
- return (_jsx(Text, { testId: `field-value-${field.field}`, children: applyFormatSpec({
311
+ return (_jsx(Text, { testId: valueTestId, children: applyFormatSpec({
311
312
  ...renderer,
312
313
  locale: stringPropertyOf(renderer, "locale") ?? appLocale,
313
314
  }, field.value, t) }));
@@ -318,7 +319,7 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
318
319
  }
319
320
  rendererWarn(`[kumiko] fieldRenderer "${componentName}" not registered`);
320
321
  }
321
- return _jsx(Text, { testId: `field-value-${field.field}`, children: stringValue(field.value) });
322
+ return _jsx(Text, { testId: valueTestId, children: stringValue(field.value) });
322
323
  }
323
324
  // Read-only text for `type: "reference"` — resolves the referenced row's
324
325
  // label via the same lookup query as ReferenceInput's combobox, but shows
@@ -354,7 +355,10 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
354
355
  const row = rows.find((r) => String(r["id"] ?? "") === id);
355
356
  if (row === undefined) {
356
357
  const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${refFeature}:${refEntity}`];
357
- const label = unknownKey !== undefined && !queryResult.loading && queryResult.error === null
358
+ const label = unknownKey !== undefined &&
359
+ !queryResult.loading &&
360
+ queryResult.error === null &&
361
+ isCompleteRowPage(queryResult.data)
358
362
  ? t(unknownKey)
359
363
  : id;
360
364
  return { id, label, link: undefined };
@@ -0,0 +1,27 @@
1
+ import type { IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { usePrimitives } from "../primitives.tsx";
4
+ export type ToolbarActionButton = {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ readonly style?: "primary" | "secondary" | "danger";
8
+ readonly confirm?: string;
9
+ readonly confirmLabel?: string;
10
+ /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
11
+ * navigate/drawer actions set it to false: the colour marks the action as
12
+ * destructive, but the target form is the confirmation. */
13
+ readonly confirmRequired?: boolean;
14
+ readonly onTrigger: () => Promise<void> | void;
15
+ /** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
16
+ * ToolbarAction has no author-declared icon field, unlike RowAction. */
17
+ readonly icon?: IconKey;
18
+ };
19
+ export declare function ToolbarActionView({ action, iconOnly, Button, Dialog, Banner, }: {
20
+ readonly action: ToolbarActionButton;
21
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
22
+ * effect when this action actually resolved an icon. */
23
+ readonly iconOnly?: boolean;
24
+ readonly Button: ReturnType<typeof usePrimitives>["Button"];
25
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
26
+ readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
27
+ }): ReactNode;
@@ -0,0 +1,40 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { needsActionConfirm } from "./render-edit-action-button.js";
4
+ // ToolbarActionView renders one button per toolbar action, with a confirm
5
+ // dialog when required. It follows the inline RowAction pattern but has no
6
+ // row context. The busy state covers async onTrigger; the dialog opens before
7
+ // the trigger when confirmation is required for confirm/danger actions.
8
+ export function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }) {
9
+ const [busy, setBusy] = useState(false);
10
+ const [confirmOpen, setConfirmOpen] = useState(false);
11
+ // Toolbar actions do not have access to the toast primitive, unlike
12
+ // rowActions routed through the DataTable primitive, so an inline error
13
+ // banner is used to surface failures. Without it, a swallowed failure result
14
+ // would look to the user as though nothing happened.
15
+ const [errorText, setErrorText] = useState(null);
16
+ const trigger = async () => {
17
+ setBusy(true);
18
+ setErrorText(null);
19
+ try {
20
+ await action.onTrigger();
21
+ }
22
+ catch (e) {
23
+ setErrorText(e instanceof Error ? e.message : String(e));
24
+ }
25
+ finally {
26
+ setBusy(false);
27
+ }
28
+ };
29
+ const variant = action.style ?? "secondary";
30
+ const needsConfirm = needsActionConfirm(action);
31
+ const showIconOnly = iconOnly && action.icon !== undefined;
32
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "md", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
33
+ if (needsConfirm) {
34
+ setConfirmOpen(true);
35
+ }
36
+ else {
37
+ void trigger();
38
+ }
39
+ }, testId: `render-list-toolbar-action-${action.id}`, children: showIconOnly ? null : action.label }), _jsx(Dialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: trigger, testId: `render-list-toolbar-action-${action.id}-dialog` }), errorText !== null && (_jsx(Banner, { variant: "error", testId: `render-list-toolbar-action-${action.id}-error`, children: errorText }))] }));
40
+ }
@@ -1,9 +1,11 @@
1
- import { type EntityDefinition, type EntityListScreenDefinition, type IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import { type EntityDefinition, type EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ReactNode } from "react";
3
3
  import type { ListRowViewModel, Translate } from "../headless/index.ts";
4
4
  import type { ListSort } from "../hooks/use-list-url-state.ts";
5
- import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableRowGrouping } from "../primitives.tsx";
5
+ import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableTableBlockProps } from "../primitives.tsx";
6
6
  import { type ListEmptyStateContent } from "./list-empty-state.tsx";
7
+ import { type ToolbarActionButton } from "./render-list-toolbar-action.tsx";
8
+ export type { ToolbarActionButton };
7
9
  export type RenderListProps = {
8
10
  readonly screen: EntityListScreenDefinition;
9
11
  readonly entity: Pick<EntityDefinition, "fields" | "derivedFields">;
@@ -67,12 +69,11 @@ export type RenderListProps = {
67
69
  * EntityListScreenDefinition.rowActions: handler-QN becomes a dispatcher
68
70
  * call, and i18n keys become translated strings. */
69
71
  readonly rowActions?: readonly DataTableRowAction[];
70
- /** How the row-action column renders (see `DataTableProps.rowActionMode`).
71
- * KumikoScreen derives it from the actions themselves; without it the
72
- * DataTable falls back to its adaptive default. */
72
+ /** How the row-action column renders (see `DataTableProps.rowActionMode`). KumikoScreen
73
+ * derives it from the actions; without it the DataTable uses its adaptive default. */
73
74
  readonly rowActionMode?: DataTableRowActionMode;
74
- /** Forwarded to `DataTableProps.rowGrouping` / `rowTone`. */
75
- readonly rowGrouping?: DataTableRowGrouping;
75
+ /** Forwarded to `DataTableProps.bulk` / `rowTone`. */
76
+ readonly bulk?: DataTableProps["bulk"];
76
77
  readonly rowTone?: DataTableProps["rowTone"];
77
78
  /** Toolbar actions in the list header in resolved form. KumikoScreen
78
79
  * builds them from EntityListScreenDefinition.toolbarActions:
@@ -93,13 +94,11 @@ export type RenderListProps = {
93
94
  readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
94
95
  /** One bound changed (set URL state + refetch). */
95
96
  readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
96
- /** Forwarded to `DataTableProps.chromeless` — drops the table's own card
97
- * frame for a host that already provides one (relatedList in a tabs-mode
98
- * section). Default false. */
97
+ /** Forwarded to `DataTableProps.chromeless` — drops the table's own card frame for a
98
+ * host that already provides one (relatedList in a tabs-mode section). Default false. */
99
99
  readonly chromeless?: boolean;
100
- /** Forwarded to `DataTableProps.scrollBody` — bounds the table height and
101
- * scrolls rows internally instead of growing the page (relatedList in a
102
- * tabs-mode section). Default false. */
100
+ /** Forwarded to `DataTableProps.scrollBody` — bounds the table height and scrolls rows
101
+ * internally instead of growing the page (relatedList in a tabs-mode section). Default false. */
103
102
  readonly scrollBody?: boolean;
104
103
  /** Forwarded to `DataTableProps.screenPadding` — `true`: the table carries
105
104
  * the shared screen padding because it is the screen body (entityList/
@@ -112,20 +111,5 @@ export type RenderListProps = {
112
111
  readonly expandedRowIds?: ReadonlySet<string>;
113
112
  readonly onToggleRowExpanded?: (rowId: string) => void;
114
113
  readonly renderExpandedRow?: (row: ListRowViewModel) => ReactNode;
115
- };
116
- export type ToolbarActionButton = {
117
- readonly id: string;
118
- readonly label: string;
119
- readonly style?: "primary" | "secondary" | "danger";
120
- readonly confirm?: string;
121
- readonly confirmLabel?: string;
122
- /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
123
- * navigate/drawer actions set it to false: the colour marks the action as
124
- * destructive, but the target form is the confirmation. */
125
- readonly confirmRequired?: boolean;
126
- readonly onTrigger: () => Promise<void> | void;
127
- /** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
128
- * ToolbarAction has no author-declared icon field, unlike RowAction. */
129
- readonly icon?: IconKey;
130
- };
114
+ } & DataTableTableBlockProps;
131
115
  export declare function RenderList(props: RenderListProps): ReactNode;