@cosmicdrift/kumiko-renderer 0.348.1 → 0.350.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.
@@ -3,8 +3,10 @@ export type FacetCountQuery = {
3
3
  readonly key: string;
4
4
  readonly payload: Readonly<Record<string, unknown>>;
5
5
  };
6
- export declare function FacetCountBridges({ queryType, queries, onCount, }: {
6
+ export declare function FacetCountBridges({ queryType, queries, onCount, refreshNonce, }: {
7
7
  readonly queryType: string;
8
8
  readonly queries: readonly FacetCountQuery[];
9
9
  readonly onCount: (key: string, count: number) => void;
10
+ /** Bumped after a write; every bridge refetches its count. */
11
+ readonly refreshNonce: number;
10
12
  }): ReactNode;
@@ -2,19 +2,29 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  // One invisible bridge per facet chip, mirroring ReferenceFacetBridges — the
3
3
  // chip count varies per screen, so useQuery can't be called in a loop. Each
4
4
  // bridge asks the list query for a single row plus `total`.
5
- // ponytail: one extra list query per chip, refetched on every list
6
- // invalidation; a server-side facet-count handler replaces this if it hurts.
7
- import { useEffect } from "react";
5
+ // ponytail: one extra list query per chip, refetched with the list (live
6
+ // events and `refreshNonce` bumps after writes); a server-side facet-count
7
+ // handler replaces this if it hurts.
8
+ import { useEffect, useRef } from "react";
8
9
  import { useQuery } from "../hooks/use-query.js";
9
- function FacetCountBridge({ countKey, queryType, payload, onCount, }) {
10
- const result = useQuery(queryType, payload);
10
+ function FacetCountBridge({ countKey, queryType, payload, onCount, refreshNonce, }) {
11
+ const result = useQuery(queryType, payload, { live: true });
11
12
  const total = result.data?.total;
12
13
  useEffect(() => {
13
14
  if (total !== undefined)
14
15
  onCount(countKey, total);
15
16
  }, [total, countKey, onCount]);
17
+ const { refetch } = result;
18
+ const seenNonce = useRef(refreshNonce);
19
+ useEffect(() => {
20
+ // skip: the mount fetch already covers the nonce the bridge started with
21
+ if (seenNonce.current === refreshNonce)
22
+ return;
23
+ seenNonce.current = refreshNonce;
24
+ void refetch();
25
+ }, [refreshNonce, refetch]);
16
26
  return null;
17
27
  }
18
- export function FacetCountBridges({ queryType, queries, onCount, }) {
19
- return (_jsx(_Fragment, { children: queries.map((query) => (_jsx(FacetCountBridge, { countKey: query.key, queryType: queryType, payload: query.payload, onCount: onCount }, query.key))) }));
28
+ export function FacetCountBridges({ queryType, queries, onCount, refreshNonce, }) {
29
+ return (_jsx(_Fragment, { children: queries.map((query) => (_jsx(FacetCountBridge, { countKey: query.key, queryType: queryType, payload: query.payload, onCount: onCount, refreshNonce: refreshNonce }, query.key))) }));
20
30
  }
@@ -1193,7 +1193,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1193
1193
  cursor,
1194
1194
  ]);
1195
1195
  const rowsQuery = useQuery(queryType, queryPayload, { live: true });
1196
+ // Facet counts come from their own queries, which a write leaves untouched.
1197
+ const [facetCountRefreshNonce, setFacetCountRefreshNonce] = useState(0);
1198
+ const refreshFacetCounts = useCallback(() => setFacetCountRefreshNonce((n) => n + 1), []);
1196
1199
  const refreshRowsAfterWrite = useCallback(async () => {
1200
+ refreshFacetCounts();
1197
1201
  if (useInfinite) {
1198
1202
  setAccumulated([]);
1199
1203
  setCursor(undefined);
@@ -1201,7 +1205,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1201
1205
  return;
1202
1206
  }
1203
1207
  await refetchAfterWrite(rowsQuery.refetch);
1204
- }, [useInfinite, rowsQuery.refetch]);
1208
+ }, [useInfinite, rowsQuery.refetch, refreshFacetCounts]);
1205
1209
  // Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
1206
1210
  // hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
1207
1211
  // initiale Loads laufen beide hier durch — das useEffect-Dep-Array
@@ -1510,7 +1514,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1510
1514
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1511
1515
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1512
1516
  const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1513
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1517
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1514
1518
  ? { screenPadding: false, chromeless: true }
1515
1519
  : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
1516
1520
  onReachEnd: loadMore,
@@ -1528,6 +1532,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1528
1532
  }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1529
1533
  closeDrawer();
1530
1534
  invalidateExpansions();
1535
+ refreshFacetCounts();
1531
1536
  void rowsQuery.refetch();
1532
1537
  } })] }));
1533
1538
  }
package/dist/app/nav.d.ts CHANGED
@@ -16,23 +16,28 @@ export type ObjectTarget = {
16
16
  readonly id: string;
17
17
  };
18
18
  export type NavTarget = ScreenTarget | ObjectTarget;
19
+ export type NavigateOptions = {
20
+ /** Search params of the target route, set in the same history entry as
21
+ * the path. Without them the target starts with an empty query. */
22
+ readonly searchParams?: Readonly<Record<string, string>>;
23
+ };
19
24
  export type NavApi = {
20
25
  /** Current route — `undefined` when the URL is at the root / there's
21
26
  * no route selected. Caller's initial fallback kicks in then. */
22
27
  readonly route: NavRoute | undefined;
23
28
  /** Push a new route. Platform-specific Impl writes to
24
29
  * history/stack and notifies subscribers. */
25
- readonly navigate: (target: NavTarget) => void;
30
+ readonly navigate: (target: NavTarget, options?: NavigateOptions) => void;
26
31
  /** Replace the current route in place. Same effect as navigate from
27
32
  * the user's perspective, but doesn't add a history entry — used for
28
33
  * mount-time URL fills (e.g. WorkspaceShell defaulting to `/admin/x`
29
34
  * when the user typed `/`). Browser Back must take the user out of
30
35
  * the app, not back to the original empty path. */
31
- readonly replace: (target: NavTarget) => void;
36
+ readonly replace: (target: NavTarget, options?: NavigateOptions) => void;
32
37
  /** Build the href a click on {target} would produce. Used by
33
38
  * platform-specific Link-Komponenten (Web: `<a href>`; Native
34
39
  * typically doesn't need this). */
35
- readonly hrefFor: (target: NavTarget) => string;
40
+ readonly hrefFor: (target: NavTarget, options?: NavigateOptions) => string;
36
41
  /** Lese-Snapshot der aktuellen Search-Params (Browser: ?key=value-
37
42
  * Pairs nach dem Pfad). Native-Impls die kein URL-Konzept haben
38
43
  * liefern ein leeres Object. Wert ist ein Plain-Record (kein Map)
@@ -41,4 +41,6 @@ export type ListUrlStateApi = ListUrlState & {
41
41
  }) => void;
42
42
  };
43
43
  export declare const PAGE_SIZE_OPTIONS: readonly number[];
44
+ /** URL key of a list facet filter. Its value is the comma-joined selected values. */
45
+ export declare function listFilterUrlKey(screenId: string, field: string): string;
44
46
  export declare function useListUrlState(screenId: string, defaultFilters?: Readonly<Record<string, readonly string[]>>): ListUrlStateApi;
@@ -33,6 +33,10 @@ function parsePageSize(value) {
33
33
  function key(screenId, suffix) {
34
34
  return `${screenId}.${suffix}`;
35
35
  }
36
+ /** URL key of a list facet filter. Its value is the comma-joined selected values. */
37
+ export function listFilterUrlKey(screenId, field) {
38
+ return key(screenId, `f.${field}`);
39
+ }
36
40
  function parseDir(value) {
37
41
  return value === "asc" || value === "desc" ? value : undefined;
38
42
  }
@@ -55,7 +59,7 @@ export function useListUrlState(screenId, defaultFilters) {
55
59
  const q = params[key(screenId, "q")] ?? "";
56
60
  const page = parsePage(params[key(screenId, "page")]);
57
61
  const pageSize = parsePageSize(params[key(screenId, "size")]);
58
- const filterPrefix = `${screenId}.f.`;
62
+ const filterPrefix = listFilterUrlKey(screenId, "");
59
63
  const filters = useMemo(() => {
60
64
  const out = {};
61
65
  for (const [k, v] of Object.entries(params)) {
@@ -110,7 +114,7 @@ export function useListUrlState(screenId, defaultFilters) {
110
114
  }, [nav, screenId]);
111
115
  const setFilter = useCallback((field, values) => {
112
116
  nav.setSearchParams({
113
- [key(screenId, `f.${field}`)]: values.length === 0 ? clearedFilterValue(field) : values.join(","),
117
+ [listFilterUrlKey(screenId, field)]: values.length === 0 ? clearedFilterValue(field) : values.join(","),
114
118
  [key(screenId, "page")]: null,
115
119
  });
116
120
  }, [nav, screenId, clearedFilterValue]);
package/dist/index.d.ts CHANGED
@@ -22,7 +22,7 @@ export type { KumikoScreenProps } from "./app/kumiko-screen.js";
22
22
  export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen.js";
23
23
  export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets.js";
24
24
  export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.js";
25
- export type { NavApi, NavProviderProps, NavRoute, NavTarget, ObjectTarget, ScreenTarget, } from "./app/nav.js";
25
+ export type { NavApi, NavigateOptions, NavProviderProps, NavRoute, NavTarget, ObjectTarget, ScreenTarget, } from "./app/nav.js";
26
26
  export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, } from "./app/nav.js";
27
27
  export { lastSegment } from "./app/qn.js";
28
28
  export type { ReturnHost, ReturnTo } from "./app/return-to.js";
@@ -57,7 +57,7 @@ export { useDisclosure } from "./hooks/use-disclosure.js";
57
57
  export type { UseFormOptions, UseFormResult } from "./hooks/use-form.js";
58
58
  export { useForm } from "./hooks/use-form.js";
59
59
  export type { ListSort, ListSortDir, ListUrlState, ListUrlStateApi, } from "./hooks/use-list-url-state.js";
60
- export { useListUrlState } from "./hooks/use-list-url-state.js";
60
+ export { listFilterUrlKey, useListUrlState } from "./hooks/use-list-url-state.js";
61
61
  export type { UseMutationResult } from "./hooks/use-mutation.js";
62
62
  export { useMutation } from "./hooks/use-mutation.js";
63
63
  export type { UseQueryOptions, UseQueryResult } from "./hooks/use-query.js";
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ export { REFERENCE_COMBOBOX_LIMIT, REFERENCE_LIST_LOOKUP_LIMIT, REFERENCE_SEARCH
42
42
  export { AI_TEXT_RUN_QN, useAiTextAction, useCompletion } from "./hooks/use-ai-text.js";
43
43
  export { useDisclosure } from "./hooks/use-disclosure.js";
44
44
  export { useForm } from "./hooks/use-form.js";
45
- export { useListUrlState } from "./hooks/use-list-url-state.js";
45
+ export { listFilterUrlKey, useListUrlState } from "./hooks/use-list-url-state.js";
46
46
  export { useMutation } from "./hooks/use-mutation.js";
47
47
  export { entityFromQueryType, useQuery } from "./hooks/use-query.js";
48
48
  export { useReportStepComplete } from "./hooks/use-report-step-complete.js";
@@ -1081,6 +1081,12 @@ type LightboxBaseProps = {
1081
1081
  readonly open: boolean;
1082
1082
  readonly onOpenChange: (open: boolean) => void;
1083
1083
  readonly testId?: string;
1084
+ /** Controls for the shown image, e.g. a download button. Rendered in the
1085
+ * top-left corner; with a set the app picks the image from its `index`. */
1086
+ readonly actions?: ReactNode;
1087
+ /** `false` hides the `{current} / {total}` counter of a set, for images
1088
+ * that carry their own. Default `true`. */
1089
+ readonly showPosition?: boolean;
1084
1090
  };
1085
1091
  /** Image lightbox — full-size preview on click. Web renders Radix overlay;
1086
1092
  * trigger (thumbnail) and open state live in the app. Takes either one image
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.348.1",
3
+ "version": "0.350.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.348.1",
19
- "@cosmicdrift/kumiko-headless": "0.348.1",
20
- "@cosmicdrift/kumiko-types": "0.348.1",
18
+ "@cosmicdrift/kumiko-framework": "0.350.0",
19
+ "@cosmicdrift/kumiko-headless": "0.350.0",
20
+ "@cosmicdrift/kumiko-types": "0.350.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.348.1"
31
+ "@cosmicdrift/kumiko-locale-de": "0.350.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",