@cosmicdrift/kumiko-renderer 0.326.1 → 0.328.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.
@@ -11,7 +11,7 @@ import {
11
11
  type EditFieldViewModel,
12
12
  type FieldIssue,
13
13
  } from "@cosmicdrift/kumiko-headless";
14
- import { type ReactNode, useCallback, useMemo, useState } from "react";
14
+ import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
15
15
  import { useAppFeatures } from "../app/app-features-context";
16
16
  import { useColumnRenderer } from "../app/column-renderers";
17
17
  import { extensionSectionName } from "../app/extension-sections";
@@ -71,8 +71,14 @@ export type RenderFieldProps = {
71
71
  * field's sibling-field `unit`. Omitted → falls back to a single-key
72
72
  * `{ [field.field]: field.value }` row. */
73
73
  readonly row?: Readonly<Record<string, unknown>>;
74
+ /** Marks the field as modified against the loaded record (edit mode only). */
75
+ readonly changed?: boolean;
74
76
  };
75
77
 
78
+ const AllFieldsRequiredContext = createContext(false);
79
+
80
+ export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
81
+
76
82
  export function RenderField({
77
83
  field,
78
84
  issues,
@@ -83,6 +89,7 @@ export function RenderField({
83
89
  allIssues,
84
90
  valueDisplay = "form",
85
91
  row,
92
+ changed,
86
93
  }: RenderFieldProps): ReactNode {
87
94
  const { Field, Input, Banner, Text, JsonView } = usePrimitives();
88
95
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
@@ -92,6 +99,7 @@ export function RenderField({
92
99
  // Standalone-Consumer/Tests müssen wrappen (createKumikoApp tut es).
93
100
  const appLocale = useLocale().locale();
94
101
  const t = useTranslation();
102
+ const allFieldsRequired = useContext(AllFieldsRequiredContext);
95
103
  if (!field.visible) return null;
96
104
 
97
105
  const id = inputId(field);
@@ -157,10 +165,11 @@ export function RenderField({
157
165
  <Field
158
166
  id={id}
159
167
  label={field.label}
160
- required={field.required}
168
+ required={field.required && !allFieldsRequired}
161
169
  {...(issues !== undefined && { issues })}
162
170
  {...(labelAppendix !== undefined && { labelAppendix })}
163
171
  {...(fieldAppendix !== undefined && { fieldAppendix })}
172
+ {...(changed === true && { changed })}
164
173
  testId={`field-${field.field}`}
165
174
  >
166
175
  {control}
@@ -17,6 +17,7 @@ import { extensionSectionName, useExtensionSectionComponent } from "../app/exten
17
17
  import type { ListSort } from "../hooks/use-list-url-state";
18
18
  import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
19
19
  import { useTranslation } from "../i18n";
20
+ import { usePageHeaderSlotAvailable } from "../page-header-slot";
20
21
  import {
21
22
  type DataTableDateRangeFacet,
22
23
  type DataTableFacet,
@@ -63,6 +64,10 @@ export type RenderListProps = {
63
64
  /** Placeholder für das Search-Input. Default kommt aus dem i18n-
64
65
  * Bundle (`kumiko.list.search-placeholder`). */
65
66
  readonly searchPlaceholder?: string;
67
+ /** Hint at the left of the toolbar when there is no search box (already translated). */
68
+ readonly description?: string;
69
+ /** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
70
+ readonly itemNounKey?: string;
66
71
  /** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
67
72
  * Tipps lokal mit 300ms Debounce, bevor onSearchChange gefeuert
68
73
  * wird — sonst macht jeder Tastendruck einen Server-Roundtrip. */
@@ -167,6 +172,8 @@ export function RenderList(props: RenderListProps): ReactNode {
167
172
  createLabel,
168
173
  searchable = false,
169
174
  searchPlaceholder,
175
+ description,
176
+ itemNounKey,
170
177
  searchValue,
171
178
  onSearchChange,
172
179
  sort,
@@ -192,7 +199,10 @@ export function RenderList(props: RenderListProps): ReactNode {
192
199
  // wären Column-Header raw i18n-Keys.
193
200
  const t = useTranslation();
194
201
  const translate: Translate = translateProp ?? t;
195
- const { DataTable, Button, Dialog, Input, Text, Banner } = usePrimitives();
202
+ const { DataTable, Button, Dialog, Input, Text, Banner, PageHeader } = usePrimitives();
203
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
204
+ const createInPageHeader =
205
+ onCreate !== undefined && PageHeader !== undefined && pageHeaderSlotAvailable;
196
206
 
197
207
  // Local search buffer + debounce. External changes (browser back,
198
208
  // cross-component reset) are mirrored back; typing only fires
@@ -300,9 +310,19 @@ export function RenderList(props: RenderListProps): ReactNode {
300
310
  // i18n-Defaults für Toolbar/Empty-State Strings — Caller kann jeden
301
311
  // einzeln per Prop überschreiben, sonst kommen die Framework-Bundles
302
312
  // (kumiko.actions.create, kumiko.list.search-placeholder, …).
303
- const effectiveCreateLabel = createLabel ?? translate("kumiko.actions.create");
304
- const effectiveSearchPlaceholder =
305
- searchPlaceholder ?? translate("kumiko.list.search-placeholder");
313
+ const effectiveCreateLabel = translate(
314
+ createLabel ?? screen.createLabel ?? "kumiko.actions.create",
315
+ );
316
+ const effectiveSearchPlaceholder = translate(
317
+ searchPlaceholder ?? screen.searchPlaceholder ?? "kumiko.list.search-placeholder",
318
+ );
319
+
320
+ const nounKey = itemNounKey ?? `${featureName}:entity:${screen.entity}:noun`;
321
+ const itemNoun =
322
+ (itemNounKey !== undefined || !screen.entity.startsWith("__")) &&
323
+ translate(nounKey, { count: 2 }) !== nounKey
324
+ ? (count: number): string => translate(nounKey, { count })
325
+ : undefined;
306
326
 
307
327
  const toolbarStart = searchable ? (
308
328
  <Input
@@ -324,8 +344,20 @@ export function RenderList(props: RenderListProps): ReactNode {
324
344
  const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
325
345
  const hasHeaderSlot = screen.slots?.header !== undefined;
326
346
  const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
347
+ const createButton =
348
+ onCreate !== undefined ? (
349
+ <Button
350
+ variant="primary"
351
+ icon="plus"
352
+ onClick={onCreate}
353
+ testId="render-list-create"
354
+ className="px-3.5"
355
+ >
356
+ {effectiveCreateLabel}
357
+ </Button>
358
+ ) : undefined;
327
359
  const toolbarEnd =
328
- hasHeaderSlot || hasToolbarActions || onCreate !== undefined ? (
360
+ hasHeaderSlot || hasToolbarActions || (onCreate !== undefined && !createInPageHeader) ? (
329
361
  <>
330
362
  {hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
331
363
  {hasToolbarActions &&
@@ -339,11 +371,7 @@ export function RenderList(props: RenderListProps): ReactNode {
339
371
  Banner={Banner}
340
372
  />
341
373
  ))}
342
- {onCreate !== undefined && (
343
- <Button variant="primary" onClick={onCreate} testId="render-list-create">
344
- {`+ ${effectiveCreateLabel}`}
345
- </Button>
346
- )}
374
+ {!createInPageHeader && createButton}
347
375
  </>
348
376
  ) : undefined;
349
377
 
@@ -357,8 +385,8 @@ export function RenderList(props: RenderListProps): ReactNode {
357
385
  <>
358
386
  <Text>{translate("kumiko.list.empty.title")}</Text>
359
387
  <Text variant="small">{translate("kumiko.list.empty.hint")}</Text>
360
- <Button variant="primary" onClick={onCreate} testId="render-list-empty-create">
361
- {`+ ${effectiveCreateLabel}`}
388
+ <Button variant="primary" icon="plus" onClick={onCreate} testId="render-list-empty-create">
389
+ {effectiveCreateLabel}
362
390
  </Button>
363
391
  </>
364
392
  ) : undefined);
@@ -374,6 +402,7 @@ export function RenderList(props: RenderListProps): ReactNode {
374
402
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
375
403
  return (
376
404
  <>
405
+ {createInPageHeader && PageHeader !== undefined && <PageHeader actions={createButton} />}
377
406
  {referenceColumns.map(
378
407
  (rc: { field: string; refEntity: string; refFeature: string; labelField: string }) => (
379
408
  <ReferenceLookupBridge
@@ -393,6 +422,7 @@ export function RenderList(props: RenderListProps): ReactNode {
393
422
  {...(onRowClick !== undefined && { onRowClick })}
394
423
  {...(composedEmptyState !== undefined && { emptyState: composedEmptyState })}
395
424
  {...(toolbarStart !== undefined && { toolbarStart })}
425
+ {...(description !== undefined && !searchable && { toolbarDescription: description })}
396
426
  {...(toolbarEnd !== undefined && { toolbarEnd })}
397
427
  {...(sort !== undefined && { sort })}
398
428
  {...(onSortChange !== undefined && { onSortChange })}
@@ -400,6 +430,7 @@ export function RenderList(props: RenderListProps): ReactNode {
400
430
  {...(onReachEnd !== undefined && { onReachEnd })}
401
431
  {...(loadingMore !== undefined && { loadingMore })}
402
432
  {...(hasMore !== undefined && { hasMore })}
433
+ {...(itemNoun !== undefined && { itemNoun })}
403
434
  {...(rowActions !== undefined && { rowActions })}
404
435
  {...(rowActionMode !== undefined && { rowActionMode })}
405
436
  {...(filterFacets !== undefined && { filterFacets })}
@@ -529,7 +560,8 @@ function ToolbarActionView({
529
560
  variant={variant}
530
561
  loading={busy}
531
562
  {...(action.icon !== undefined && { icon: action.icon })}
532
- {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
563
+ size={showIconOnly ? "icon" : "sm"}
564
+ {...(showIconOnly && { ariaLabel: action.label })}
533
565
  onClick={() => {
534
566
  if (needsConfirm) {
535
567
  setConfirmOpen(true);
@@ -144,7 +144,6 @@ export function WriteFormSection({
144
144
  <Button
145
145
  type="button"
146
146
  variant="primary"
147
- icon="check"
148
147
  disabled={isSubmitting}
149
148
  loading={isSubmitting}
150
149
  onClick={() => void handleSubmit()}
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { DispatcherProvider } from "../../context/dispatcher-context";
6
+ import { useQuery } from "../use-query";
7
+
8
+ type QueryFn = Dispatcher["query"];
9
+
10
+ function makeDispatcher(query: QueryFn): Dispatcher {
11
+ return {
12
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
13
+ query,
14
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
15
+ statusStore: {
16
+ getState: () => "online",
17
+ subscribe: () => () => {},
18
+ } as unknown as Dispatcher["statusStore"],
19
+ async *stream() {},
20
+ pendingWrites: () => [],
21
+ pendingFiles: () => [],
22
+ };
23
+ }
24
+
25
+ function wrapperFor(dispatcher: Dispatcher) {
26
+ return ({ children }: { readonly children: ReactNode }) => (
27
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
28
+ );
29
+ }
30
+
31
+ function countingDispatcher(): { dispatcher: Dispatcher; calls: () => number } {
32
+ let calls = 0;
33
+ const query = (async () => {
34
+ calls += 1;
35
+ return { isSuccess: true, data: { n: calls } };
36
+ }) as unknown as QueryFn;
37
+ return { dispatcher: makeDispatcher(query), calls: () => calls };
38
+ }
39
+
40
+ async function advance(ms: number): Promise<void> {
41
+ await act(async () => {
42
+ jest.advanceTimersByTime(ms);
43
+ });
44
+ }
45
+
46
+ const QUERY_TYPE = "f:query:x:list";
47
+
48
+ describe("useQuery refetchIntervalMs", () => {
49
+ beforeEach(() => {
50
+ jest.useFakeTimers();
51
+ });
52
+ afterEach(() => {
53
+ jest.useRealTimers();
54
+ });
55
+
56
+ test("re-runs the query on every interval tick", async () => {
57
+ const { dispatcher, calls } = countingDispatcher();
58
+ const { result } = renderHook(
59
+ () => useQuery<{ n: number }>(QUERY_TYPE, {}, { refetchIntervalMs: 1000 }),
60
+ {
61
+ wrapper: wrapperFor(dispatcher),
62
+ },
63
+ );
64
+ await advance(0);
65
+ expect(calls()).toBe(1);
66
+
67
+ await advance(1000);
68
+ expect(calls()).toBe(2);
69
+ await advance(1000);
70
+ expect(calls()).toBe(3);
71
+ expect(result.current.data).toEqual({ n: 3 });
72
+ });
73
+
74
+ test("unmount stops the ticks", async () => {
75
+ const { dispatcher, calls } = countingDispatcher();
76
+ const { unmount } = renderHook(() => useQuery(QUERY_TYPE, {}, { refetchIntervalMs: 1000 }), {
77
+ wrapper: wrapperFor(dispatcher),
78
+ });
79
+ await advance(1000);
80
+ const before = calls();
81
+ unmount();
82
+ await advance(5000);
83
+ expect(calls()).toBe(before);
84
+ });
85
+
86
+ test("enabled: false never ticks", async () => {
87
+ const { dispatcher, calls } = countingDispatcher();
88
+ renderHook(() => useQuery(QUERY_TYPE, {}, { enabled: false, refetchIntervalMs: 1000 }), {
89
+ wrapper: wrapperFor(dispatcher),
90
+ });
91
+ await advance(5000);
92
+ expect(calls()).toBe(0);
93
+ });
94
+
95
+ test("a tick while a fetch is in flight is skipped instead of aborting it", async () => {
96
+ let calls = 0;
97
+ let release: (() => void) | undefined;
98
+ const query = (async (_type: string, _payload: unknown, opts?: { signal?: AbortSignal }) => {
99
+ calls += 1;
100
+ await new Promise<void>((resolve) => {
101
+ release = resolve;
102
+ });
103
+ return opts?.signal?.aborted
104
+ ? { isSuccess: false, error: { code: "aborted", message: "aborted", i18nKey: "x" } }
105
+ : { isSuccess: true, data: { done: true } };
106
+ }) as unknown as QueryFn;
107
+ const { result } = renderHook(
108
+ () => useQuery<{ done: boolean }>(QUERY_TYPE, {}, { refetchIntervalMs: 1000 }),
109
+ {
110
+ wrapper: wrapperFor(makeDispatcher(query)),
111
+ },
112
+ );
113
+ await advance(0);
114
+ expect(calls).toBe(1);
115
+
116
+ await advance(3000);
117
+ expect(calls).toBe(1);
118
+
119
+ await act(async () => {
120
+ release?.();
121
+ });
122
+ expect(result.current.data).toEqual({ done: true });
123
+
124
+ await advance(1000);
125
+ expect(calls).toBe(2);
126
+ });
127
+
128
+ test("background ticks do not flip loading to true", async () => {
129
+ const { dispatcher, calls } = countingDispatcher();
130
+ const seenLoading: boolean[] = [];
131
+ const { result } = renderHook(
132
+ () => {
133
+ const r = useQuery<{ n: number }>(QUERY_TYPE, {}, { refetchIntervalMs: 1000 });
134
+ seenLoading.push(r.loading);
135
+ return r;
136
+ },
137
+ { wrapper: wrapperFor(dispatcher) },
138
+ );
139
+ await advance(0);
140
+ expect(result.current.loading).toBe(false);
141
+ seenLoading.length = 0;
142
+
143
+ await advance(1000);
144
+ await advance(1000);
145
+ expect(calls()).toBe(3);
146
+ expect(result.current.data).toEqual({ n: 3 });
147
+ expect(seenLoading.every((l) => l === false)).toBe(true);
148
+ });
149
+ });
@@ -30,6 +30,8 @@ export type ListUrlState = {
30
30
  /** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
31
31
  * der Wert ignoriert; Caller liest ihn nur wenn relevant. */
32
32
  readonly page: number;
33
+ /** User-chosen page size from `PAGE_SIZE_OPTIONS`; undefined = screen default. */
34
+ readonly pageSize: number | undefined;
33
35
  /** Aktive Faceted-Filter: field → ausgewählte Werte (Strings; Caller
34
36
  * coerced pro Field-Type). Leer wenn kein Filter gesetzt. */
35
37
  readonly filters: Readonly<Record<string, readonly string[]>>;
@@ -43,6 +45,8 @@ export type ListUrlStateApi = ListUrlState & {
43
45
  readonly setQ: (next: string) => void;
44
46
  /** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
45
47
  readonly setPage: (next: number) => void;
48
+ /** Sets the page size and resets the page; undefined clears the key. */
49
+ readonly setPageSize: (next: number | undefined) => void;
46
50
  /** Setzt die ausgewählten Werte eines Facet-Felds. Leeres Array löscht
47
51
  * den Key. Resettet die Page (wie sort/search). */
48
52
  readonly setFilter: (field: string, values: readonly string[]) => void;
@@ -61,6 +65,14 @@ export type ListUrlStateApi = ListUrlState & {
61
65
  // `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
62
66
  // üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
63
67
  // pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
68
+ export const PAGE_SIZE_OPTIONS: readonly number[] = [25, 50, 100];
69
+
70
+ function parsePageSize(value: string | undefined): number | undefined {
71
+ if (value === undefined) return undefined;
72
+ const n = Number.parseInt(value, 10);
73
+ return PAGE_SIZE_OPTIONS.includes(n) ? n : undefined;
74
+ }
75
+
64
76
  function key(screenId: string, suffix: string): string {
65
77
  return `${screenId}.${suffix}`;
66
78
  }
@@ -88,6 +100,7 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
88
100
 
89
101
  const q = params[key(screenId, "q")] ?? "";
90
102
  const page = parsePage(params[key(screenId, "page")]);
103
+ const pageSize = parsePageSize(params[key(screenId, "size")]);
91
104
 
92
105
  const filterPrefix = `${screenId}.f.`;
93
106
  const filters = useMemo<Readonly<Record<string, readonly string[]>>>(() => {
@@ -140,6 +153,16 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
140
153
  [nav, screenId],
141
154
  );
142
155
 
156
+ const setPageSize = useCallback(
157
+ (next: number | undefined) => {
158
+ nav.setSearchParams({
159
+ [key(screenId, "size")]: next === undefined ? null : String(next),
160
+ [key(screenId, "page")]: null,
161
+ });
162
+ },
163
+ [nav, screenId],
164
+ );
165
+
143
166
  const setFilter = useCallback(
144
167
  (field: string, values: readonly string[]) => {
145
168
  nav.setSearchParams({
@@ -167,5 +190,18 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
167
190
  nav.setSearchParams(updates);
168
191
  }, [nav, screenId, filters]);
169
192
 
170
- return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters, setDateRange };
193
+ return {
194
+ sort,
195
+ q,
196
+ page,
197
+ pageSize,
198
+ filters,
199
+ setSort,
200
+ setQ,
201
+ setPage,
202
+ setPageSize,
203
+ setFilter,
204
+ clearFilters,
205
+ setDateRange,
206
+ };
171
207
  }
@@ -38,6 +38,10 @@ export type UseQueryOptions = {
38
38
  // `<feature>:query:<entity>:<verb>` convention — if the type
39
39
  // doesn't follow that shape, live-mode is a no-op.
40
40
  readonly live?: boolean;
41
+ // Re-runs the query every N ms while enabled. Ticks are silent (no
42
+ // `loading` flip) and skipped while a fetch is still in flight. Only
43
+ // timers are used, so it behaves the same on web and React Native.
44
+ readonly refetchIntervalMs?: number;
41
45
  };
42
46
 
43
47
  // Extract the entity-name from a standard Kumiko query type. Returns
@@ -57,7 +61,7 @@ export function useQuery<TData = unknown>(
57
61
  options: UseQueryOptions = {},
58
62
  ): UseQueryResult<TData> {
59
63
  const dispatcher = useDispatcher();
60
- const { enabled = true, live = false } = options;
64
+ const { enabled = true, live = false, refetchIntervalMs } = options;
61
65
 
62
66
  const [data, setData] = useState<TData | null>(null);
63
67
  const [error, setError] = useState<DispatcherError | null>(null);
@@ -67,6 +71,9 @@ export function useQuery<TData = unknown>(
67
71
  // call can cancel the older one. Ref, not state, because the
68
72
  // controller identity shouldn't trigger a re-render.
69
73
  const activeCtrl = useRef<AbortController | null>(null);
74
+ // A tick that aborted a running fetch would starve slow queries, so
75
+ // ticks consult this flag instead.
76
+ const fetchInFlight = useRef(false);
70
77
 
71
78
  // Serialize payload so object-identity-changes across renders (the
72
79
  // caller building a fresh `{}` every render) don't loop. Strings are
@@ -77,29 +84,42 @@ export function useQuery<TData = unknown>(
77
84
  // are the meaningful re-run triggers. `payload` itself is intentionally
78
85
  // not in deps — it's serialized into payloadKey above.
79
86
  // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
80
- const run = useCallback(async (): Promise<void> => {
81
- // Abort whatever's in flight. observer on Safari 17+ handles this
82
- // without raising a fetch-throw in the previous caller — they
83
- // already set the error path.
84
- activeCtrl.current?.abort();
85
- const ctrl = new AbortController();
86
- activeCtrl.current = ctrl;
87
+ const runFetch = useCallback(
88
+ async (background: boolean): Promise<void> => {
89
+ // Abort whatever's in flight. observer on Safari 17+ handles this
90
+ // without raising a fetch-throw in the previous caller — they
91
+ // already set the error path.
92
+ activeCtrl.current?.abort();
93
+ const ctrl = new AbortController();
94
+ activeCtrl.current = ctrl;
87
95
 
88
- setLoading(true);
89
- const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
90
- // skip: a newer fetch already superseded this one, don't clobber its state
91
- if (ctrl.signal.aborted) return;
92
- if (result.isSuccess) {
93
- setData(result.data);
94
- setError(null);
95
- } else {
96
- // A cancelled request comes back with code "aborted" from the
97
- // skip: aborted request, a newer run already replaces this result
98
- if (result.error.code === "aborted") return;
99
- setError(result.error);
100
- }
101
- setLoading(false);
102
- }, [dispatcher, type, payloadKey]);
96
+ fetchInFlight.current = true;
97
+ if (!background) setLoading(true);
98
+ let result: Awaited<ReturnType<typeof dispatcher.query<TData>>>;
99
+ try {
100
+ result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
101
+ } finally {
102
+ // A rejected fetch must not leave polling stuck; a superseded one must
103
+ // not clear the newer fetch's flag.
104
+ if (activeCtrl.current === ctrl) fetchInFlight.current = false;
105
+ }
106
+ // skip: a newer fetch already superseded this one, don't clobber its state
107
+ if (ctrl.signal.aborted) return;
108
+ if (result.isSuccess) {
109
+ setData(result.data);
110
+ setError(null);
111
+ } else {
112
+ // A cancelled request comes back with code "aborted" from the
113
+ // skip: aborted request, a newer run already replaces this result
114
+ if (result.error.code === "aborted") return;
115
+ setError(result.error);
116
+ }
117
+ setLoading(false);
118
+ },
119
+ [dispatcher, type, payloadKey],
120
+ );
121
+
122
+ const run = useCallback((): Promise<void> => runFetch(false), [runFetch]);
103
123
 
104
124
  useEffect(() => {
105
125
  if (!enabled) {
@@ -110,9 +130,23 @@ export function useQuery<TData = unknown>(
110
130
  void run();
111
131
  return () => {
112
132
  activeCtrl.current?.abort();
133
+ fetchInFlight.current = false;
113
134
  };
114
135
  }, [enabled, run]);
115
136
 
137
+ useEffect(() => {
138
+ // skip: polling off or disabled query
139
+ if (!enabled || refetchIntervalMs === undefined) return;
140
+ // skip: non-positive or non-finite interval disables polling
141
+ if (!Number.isFinite(refetchIntervalMs) || refetchIntervalMs <= 0) return;
142
+ const timer = setInterval(() => {
143
+ // skip: a fetch is still running, the next tick retries
144
+ if (fetchInFlight.current) return;
145
+ void runFetch(true);
146
+ }, refetchIntervalMs);
147
+ return () => clearInterval(timer);
148
+ }, [enabled, refetchIntervalMs, runFetch]);
149
+
116
150
  // Live-mode: auf SSE-Events für die Query-Entity hören und refetchen.
117
151
  // Separater Effect, damit eine Änderung an `live` oder `type` das
118
152
  // Subscription-Lifecycle genau einmal durchwalzt.
@@ -65,15 +65,31 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
65
65
  "kumiko.list.sort.label": "Sort",
66
66
  "kumiko.list.sort.unsorted": "Unsorted",
67
67
  "kumiko.list.filter.reset": "Reset",
68
+ "kumiko.list.filter.toggle": "Filters",
68
69
  "kumiko.list.related-list-truncated":
69
70
  "Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
70
71
  "kumiko.reference.system-tenant": "System",
71
72
  "kumiko.reference.system-user": "System",
72
73
 
73
74
  "kumiko.pager.status": "{from}–{to} of {total}",
75
+ "kumiko.pager.pageOf": "Page {page} of {pages}",
76
+ "kumiko.form.all-required": "All fields are required.",
77
+ "kumiko.pager.status.noun": "{from}–{to} of {total} {noun}",
78
+ "kumiko.pager.pageSize": "{size} per page",
79
+ "kumiko.pager.pageSizeLabel": "Entries per page",
80
+ "kumiko.list.count.noun": "{count} {noun}",
81
+ "kumiko.list.count.one": "1 entry",
82
+ "kumiko.list.count.other": "{count} entries",
83
+ "kumiko.list.row-actions.more": "More actions",
84
+ "kumiko.list.sort.by": "Sorted by {column}",
85
+ "kumiko.form.changed": "changed",
86
+ "kumiko.form.on-this-page": "On this page",
87
+ "kumiko.form.unsaved.one": "1 unsaved change",
88
+ "kumiko.form.unsaved.other": "{count} unsaved changes",
89
+ "kumiko.wizard.next-with-title": "Next: {title}",
90
+ "kumiko.wizard.save-and-close": "Save and close",
74
91
  "kumiko.pager.previousPage": "Previous page",
75
92
  "kumiko.pager.nextPage": "Next page",
76
- "kumiko.pager.page": "Page {entry}",
77
93
 
78
94
  "kumiko.combobox.search-placeholder": "Search…",
79
95
  "kumiko.combobox.empty": "No matches.",
@@ -102,6 +118,12 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
102
118
  "kumiko.widget.drawer.maximize": "Maximize drawer width",
103
119
  "kumiko.widget.drawer.resize": "Resize drawer",
104
120
 
121
+ "kumiko.widget.floatingPanel.move": "Move panel",
122
+ "kumiko.widget.floatingPanel.resizeTop": "Resize panel from the top edge",
123
+ "kumiko.widget.floatingPanel.resizeRight": "Resize panel from the right edge",
124
+ "kumiko.widget.floatingPanel.resizeBottom": "Resize panel from the bottom edge",
125
+ "kumiko.widget.floatingPanel.resizeLeft": "Resize panel from the left edge",
126
+
105
127
  "kumiko.planCard.current": "Current plan",
106
128
  "kumiko.planCard.priceUnavailable": "Price not available",
107
129
 
@@ -117,6 +139,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
117
139
  "kumiko.dialog.confirm": "Confirm",
118
140
  "kumiko.dialog.cancel": "Cancel",
119
141
  "kumiko.dialog.close": "Close",
142
+ "kumiko.drawer.discard.title": "Discard changes?",
143
+ "kumiko.drawer.discard.body": "Your input in this form will be lost.",
144
+ "kumiko.drawer.discard.confirm": "Discard",
145
+ "kumiko.drawer.discard.cancel": "Keep editing",
120
146
 
121
147
  "kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
122
148
  "kumiko.aiText.correct": "Correct",
package/src/index.ts CHANGED
@@ -188,6 +188,8 @@ export {
188
188
  useTranslation,
189
189
  } from "./i18n";
190
190
  export { kumikoDefaultTranslations } from "./i18n-defaults";
191
+ export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer";
192
+ export { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable } from "./page-header-slot";
191
193
  export type {
192
194
  ActionMenuItemSpec,
193
195
  ActionOverflowMenuProps,
@@ -212,9 +214,11 @@ export type {
212
214
  EmbeddedListColumn,
213
215
  EmbeddedListInputProps,
214
216
  EmbeddedListTotal,
217
+ FieldCellWidth,
215
218
  FieldProps,
216
219
  FillContainerProps,
217
220
  FormProps,
221
+ FormSectionNavItem,
218
222
  FormWidth,
219
223
  GridCellProps,
220
224
  GridProps,
@@ -223,8 +227,10 @@ export type {
223
227
  JsonViewProps,
224
228
  LightboxProps,
225
229
  LinkProps,
230
+ MetricBandProps,
226
231
  MetricProps,
227
232
  ModalProps,
233
+ PageHeaderProps,
228
234
  PrimitivesProviderProps,
229
235
  PrimitivesRegistry,
230
236
  ProgressProps,
@@ -249,6 +255,7 @@ export {
249
255
  PrimitivesProvider,
250
256
  STICKY_PRIMARY_ACTION_PROP,
251
257
  shouldRenderActionsIconOnly,
258
+ statusToneForOptionTone,
252
259
  statusToneForValue,
253
260
  usePrimitives,
254
261
  } from "./primitives";
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ // True while a form renders inside the Drawer primitive (DrawerHost). The form
4
+ // then drops its own title and card chrome (the drawer header carries the
5
+ // title) and lays out as scrolling body plus pinned footer.
6
+ const InsideDrawerContext = createContext(false);
7
+
8
+ export const InsideDrawerProvider = InsideDrawerContext.Provider;
9
+
10
+ export function useInsideDrawer(): boolean {
11
+ return useContext(InsideDrawerContext);
12
+ }
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ // True while the screen renders below an app shell that offers page-header
4
+ // slots (see the PageHeader primitive). Embedded hosts (drawer, dashboard
5
+ // panel) reset it to false so their inner screens keep their own placement.
6
+ const PageHeaderSlotAvailableContext = createContext(false);
7
+
8
+ export const PageHeaderSlotAvailableProvider = PageHeaderSlotAvailableContext.Provider;
9
+
10
+ export function usePageHeaderSlotAvailable(): boolean {
11
+ return useContext(PageHeaderSlotAvailableContext);
12
+ }