@cosmicdrift/kumiko-renderer-web 0.220.1 → 0.221.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.220.1",
3
+ "version": "0.221.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.220.1",
20
- "@cosmicdrift/kumiko-headless": "0.220.1",
21
- "@cosmicdrift/kumiko-renderer": "0.220.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.221.0",
20
+ "@cosmicdrift/kumiko-headless": "0.221.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.221.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.220.1"
67
+ "@cosmicdrift/kumiko-locale-de": "0.221.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -26,7 +26,7 @@ describe("DefaultForm Action-Footer", () => {
26
26
  expect(actions.className).toContain("border-t");
27
27
  // Footer sits in the form shell (buttons align with the field edge).
28
28
  expect(actions.closest(".max-w-full")).toBeTruthy();
29
- // Titel ist ein eigenes Heading oben, NICHT mehr in der Action-Bar.
29
+ // Title is its own heading above not inside the action bar.
30
30
  expect(actions.textContent).not.toContain("Titel");
31
31
  expect(screen.getByTestId("f-title").textContent).toBe("Titel");
32
32
  });
@@ -1570,7 +1570,7 @@ describe("KumikoScreen", () => {
1570
1570
  expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
1571
1571
  });
1572
1572
 
1573
- test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect, entityId})", async () => {
1573
+ test("actionForm mit redirect auf entityList: nach success → navigate ohne entityId (#2419)", async () => {
1574
1574
  const navigateCalls: NavTarget[] = [];
1575
1575
  const dispatcher = makeDispatcher({
1576
1576
  write: (async () => ({
@@ -1613,9 +1613,9 @@ describe("KumikoScreen", () => {
1613
1613
  fireEvent.change(titleInput, { target: { value: "go" } });
1614
1614
  fireEvent.click(screen.getByTestId("render-edit-submit"));
1615
1615
  await waitFor(() => expect(navigateCalls.length).toBe(1));
1616
- // #2416: the handler returns a created id (`{ id: "x" }`) — it must land
1617
- // on the navigate as entityId, same as entityEdit-create.
1618
- expect(navigateCalls[0]).toEqual({ screenId: "task-list", entityId: "x" });
1616
+ // #2419: entityList targets ignore entityId do not attach a handler id.
1617
+ expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
1618
+ expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
1619
1619
  });
1620
1620
 
1621
1621
  test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
@@ -1661,7 +1661,62 @@ describe("KumikoScreen", () => {
1661
1661
  fireEvent.change(titleInput, { target: { value: "go" } });
1662
1662
  fireEvent.click(screen.getByTestId("render-edit-submit"));
1663
1663
  await waitFor(() => expect(navigateCalls.length).toBe(1));
1664
- expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list", entityId: "x" });
1664
+ // Cross-feature QN is not in this feature's schema.screens — keep prior
1665
+ // behavior without entityId (#2419).
1666
+ expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
1667
+ expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
1668
+ });
1669
+
1670
+ test("actionForm mit redirect auf entityEdit: hängt entityId an (#2419)", async () => {
1671
+ const navigateCalls: NavTarget[] = [];
1672
+ const dispatcher = makeDispatcher({
1673
+ write: (async () => ({
1674
+ isSuccess: true,
1675
+ data: { id: "x" },
1676
+ })) as unknown as Dispatcher["write"],
1677
+ });
1678
+ const memoryNav = {
1679
+ route: { screenId: "quick-add" },
1680
+ navigate: (target: NavTarget) => {
1681
+ if ("screenId" in target) navigateCalls.push(target);
1682
+ },
1683
+ replace: () => undefined,
1684
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1685
+ searchParams: {},
1686
+ setSearchParams: () => undefined,
1687
+ };
1688
+ const editScreen: EntityEditScreenDefinition = {
1689
+ id: "task-edit",
1690
+ type: "entityEdit",
1691
+ entity: "task",
1692
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1693
+ };
1694
+ const actionScreen: ActionFormScreenDefinition = {
1695
+ id: "quick-add",
1696
+ type: "actionForm",
1697
+ handler: "tasks:write:task:quick-add",
1698
+ fields: { title: { type: "text", required: true } },
1699
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1700
+ redirect: "task-edit",
1701
+ };
1702
+
1703
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
1704
+ render(
1705
+ <NavProvider value={memoryNav}>
1706
+ <DispatcherProvider dispatcher={dispatcher}>
1707
+ <KumikoScreen
1708
+ schema={{ ...schema, screens: [actionScreen, editScreen, listScreen] }}
1709
+ qn="tasks:screen:quick-add"
1710
+ />
1711
+ </DispatcherProvider>
1712
+ </NavProvider>,
1713
+ );
1714
+
1715
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1716
+ fireEvent.change(titleInput, { target: { value: "go" } });
1717
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1718
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
1719
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "x" });
1665
1720
  });
1666
1721
 
1667
1722
  // #2416: entityEdit-create passes the newly created record's id through to
@@ -193,7 +193,16 @@ describe("Input kind mapping", () => {
193
193
 
194
194
  test('kind="text" icon="not-a-real-key": unknown key → no icon, no padding', () => {
195
195
  render(
196
- <Input id="i" name="i" kind="text" value="" icon="not-a-real-key" onChange={() => {}} />,
196
+ <Input
197
+ id="i"
198
+ name="i"
199
+ kind="text"
200
+ value=""
201
+ icon={
202
+ "not-a-real-key" as unknown as import("@cosmicdrift/kumiko-framework/ui-types").FieldIconKey
203
+ }
204
+ onChange={() => {}}
205
+ />,
197
206
  );
198
207
  const input = screen.getByRole("textbox");
199
208
  expect(input.className).not.toContain("pl-8");
@@ -201,7 +210,18 @@ describe("Input kind mapping", () => {
201
210
  });
202
211
 
203
212
  test('kind="text" icon="constructor": prototype-chain key → no icon, no padding', () => {
204
- render(<Input id="i" name="i" kind="text" value="" icon="constructor" onChange={() => {}} />);
213
+ render(
214
+ <Input
215
+ id="i"
216
+ name="i"
217
+ kind="text"
218
+ value=""
219
+ icon={
220
+ "constructor" as unknown as import("@cosmicdrift/kumiko-framework/ui-types").FieldIconKey
221
+ }
222
+ onChange={() => {}}
223
+ />,
224
+ );
205
225
  const input = screen.getByRole("textbox");
206
226
  expect(input.className).not.toContain("pl-8");
207
227
  expect(document.querySelector("svg[aria-hidden='true']")).toBeNull();
@@ -1044,6 +1064,38 @@ describe("DataTable", () => {
1044
1064
  expect((screen.getByTestId("edit-name") as HTMLInputElement).value).toBe("Alice");
1045
1065
  });
1046
1066
 
1067
+ test("plain cell still fires onRowClick when onCellChange is set (stopPropagation only on editable)", () => {
1068
+ const onRowClick = mock();
1069
+ const onCellChange = mock();
1070
+ const cols = [
1071
+ {
1072
+ field: "name",
1073
+ label: "Name",
1074
+ type: "string",
1075
+ sortable: false,
1076
+ },
1077
+ {
1078
+ field: "edit",
1079
+ label: "Edit",
1080
+ type: "string",
1081
+ sortable: false,
1082
+ renderer: { react: { __component: "EditableSwatch" } },
1083
+ },
1084
+ ] as const;
1085
+ render(
1086
+ <ColumnRenderersProvider value={{ EditableSwatch }}>
1087
+ <DataTable
1088
+ columns={cols}
1089
+ rows={[{ id: "r1", values: { name: "Alice", edit: "x" } }]}
1090
+ onRowClick={onRowClick}
1091
+ onCellChange={onCellChange}
1092
+ />
1093
+ </ColumnRenderersProvider>,
1094
+ );
1095
+ fireEvent.click(screen.getByTestId("cell-r1-name"));
1096
+ expect(onRowClick).toHaveBeenCalled();
1097
+ });
1098
+
1047
1099
  test("format-spec renderer ignores onCellChange — no crash, plain formatted text", () => {
1048
1100
  const onCellChange = mock();
1049
1101
  const cols = [
@@ -16,6 +16,7 @@ import {
16
16
  } from "@cosmicdrift/kumiko-renderer";
17
17
  import { type ReactNode, useCallback, useState } from "react";
18
18
  import { z } from "zod";
19
+ import { PATCH_DRAFT_SAVE_DEBOUNCE_MS } from "../../../renderer/src/components/render-edit";
19
20
  import {
20
21
  act,
21
22
  createFakeDraftStorage,
@@ -1307,14 +1308,11 @@ describe("RenderEdit wizard mode", () => {
1307
1308
  expect(screen.queryByTestId("field-title-errors")).toBeNull();
1308
1309
  });
1309
1310
 
1310
- // fw#1901: handleWizardNext used to validate every field in the section,
1311
- // including ones currently hidden by their own condition. A required
1312
- // field that only applies conditionally (e.g. a VAT id shown only for
1313
- // companies) must not permanently block "Next" while it's hidden. Note:
1314
- // form-controller.ts's runValidate() already excludes hidden-field
1315
- // issues from every validate() call via its own `hiddenFields` set
1316
- // (form-controller.ts:200), independent of scope; this test protects
1317
- // that existing guard, not the fieldNames narrowing added here.
1311
+ // Regression for form-controller.ts's `hiddenFields` guard: a required
1312
+ // field currently hidden by its own condition (e.g. vatId when not a
1313
+ // company) must not block wizard "Next". This is independent of any
1314
+ // handleWizardNext fieldNames narrowing those paths are covered by
1315
+ // the fully-hidden-section test below.
1318
1316
  test("Weiter is not blocked by a required field that's currently hidden by its own condition", async () => {
1319
1317
  const companyEntity = {
1320
1318
  fields: {
@@ -1590,10 +1588,6 @@ describe("RenderEdit wizard mode", () => {
1590
1588
  });
1591
1589
 
1592
1590
  describe("RenderEdit wizard draft", () => {
1593
- // Mirrors render-edit.tsx's own PATCH_DRAFT_SAVE_DEBOUNCE_MS (not
1594
- // exported) — tests below wait out a full window to catch stragglers.
1595
- const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
1596
-
1597
1591
  function makeDraftWizardScreen(draft: boolean): EntityEditScreenDefinition {
1598
1592
  return {
1599
1593
  id: "orders:screen:order-wizard-draft",
@@ -2109,7 +2103,7 @@ describe("RenderEdit wizard draft", () => {
2109
2103
  // second burst) would slip past an assertion taken right after the
2110
2104
  // first save. Wait out a full extra debounce window to prove there is
2111
2105
  // no straggler.
2112
- await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 200));
2106
+ await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 1000));
2113
2107
  expect(savesSoFar()).toBe(before + 1);
2114
2108
  });
2115
2109
 
@@ -2191,7 +2185,7 @@ describe("RenderEdit wizard draft", () => {
2191
2185
  // Wait past the debounce window the patch() call armed. If discard
2192
2186
  // hadn't cleared the timer, a straggling save would land here and
2193
2187
  // resurrect the just-discarded draft.
2194
- await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 200));
2188
+ await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 1000));
2195
2189
 
2196
2190
  expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(savesAtSubmit);
2197
2191
  expect(store.current).toBeNull();
@@ -0,0 +1,125 @@
1
+ // fw#2437: DataTableCell must thread the app locale (LocaleProvider) into
2
+ // `format: "unit"` / Intl.NumberFormat cells instead of falling back to the
3
+ // runtime's default locale — list vs. detail view could otherwise disagree
4
+ // ("72.5 m²" vs "72,5 m²") for the same value.
5
+ //
6
+ // DoD-Test: rendering the SAME `{ format: "unit", unit: "m2" }` column under
7
+ // two locales must produce two DIFFERENT cell labels — not just prove the
8
+ // unit-format key exists.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import type {
12
+ EntityDefinition,
13
+ EntityListScreenDefinition,
14
+ } from "@cosmicdrift/kumiko-framework/ui-types";
15
+ import {
16
+ createStaticLocaleResolver,
17
+ type LiveEventSubscriber,
18
+ LiveEventsProvider,
19
+ LocaleProvider,
20
+ type NavApi,
21
+ NavProvider,
22
+ PrimitivesProvider,
23
+ RenderList,
24
+ TokensProvider,
25
+ } from "@cosmicdrift/kumiko-renderer";
26
+ import { cleanup, render } from "@testing-library/react";
27
+ import type { ReactElement, ReactNode } from "react";
28
+ import { defaultPrimitives } from "../primitives";
29
+ import { defaultTokens } from "../tokens";
30
+
31
+ const stubNav: NavApi = {
32
+ route: undefined,
33
+ navigate: () => {},
34
+ replace: () => {},
35
+ hrefFor: () => "",
36
+ searchParams: {},
37
+ setSearchParams: () => {},
38
+ };
39
+ const stubLiveEvents: LiveEventSubscriber = () => () => {};
40
+ const stubTokens = {
41
+ tokens: defaultTokens,
42
+ mode: "dark" as const,
43
+ setMode: () => {},
44
+ toggleMode: () => {},
45
+ };
46
+
47
+ // Same all-text synthesized entity shape as projection-list-shim.ts /
48
+ // related-list-section.tsx — a projectionList/relatedList column has no real
49
+ // EntityDefinition, only a number-typed pseudo-field per column.
50
+ const areaColumnEntity = {
51
+ fields: { area: { type: "number" } },
52
+ } as unknown as EntityDefinition;
53
+
54
+ const unitScreen: EntityListScreenDefinition = {
55
+ id: "",
56
+ type: "entityList",
57
+ entity: "__projection__",
58
+ columns: [{ field: "area", renderer: { format: "unit", unit: "m2" } }],
59
+ };
60
+
61
+ const numberScreen: EntityListScreenDefinition = {
62
+ id: "",
63
+ type: "entityList",
64
+ entity: "__projection__",
65
+ columns: [{ field: "area" }],
66
+ };
67
+
68
+ function renderAreaListUnderLocale(
69
+ locale: string,
70
+ areaValue: number,
71
+ screen: EntityListScreenDefinition,
72
+ ): string | null {
73
+ cleanup();
74
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactElement {
75
+ return (
76
+ <TokensProvider value={stubTokens}>
77
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
78
+ <PrimitivesProvider value={defaultPrimitives}>
79
+ <NavProvider value={stubNav}>
80
+ <LiveEventsProvider value={stubLiveEvents}>{children}</LiveEventsProvider>
81
+ </NavProvider>
82
+ </PrimitivesProvider>
83
+ </LocaleProvider>
84
+ </TokensProvider>
85
+ );
86
+ }
87
+ const result = render(
88
+ <RenderList
89
+ screen={screen}
90
+ entity={areaColumnEntity}
91
+ rows={[{ id: "r1", area: areaValue }]}
92
+ featureName="listing"
93
+ />,
94
+ { wrapper: Wrapper },
95
+ );
96
+ return result.getByTestId("cell-r1-area").textContent;
97
+ }
98
+
99
+ describe("RenderList — format:unit-FormatSpec threads the app locale (fw#2437)", () => {
100
+ test("dieselbe m2-Spalte unter zwei Locales rendert zwei verschiedene Beschriftungen", () => {
101
+ expect(renderAreaListUnderLocale("de-DE", 72.5, unitScreen)).toBe("72,5 m²");
102
+ expect(renderAreaListUnderLocale("en-US", 72.5, unitScreen)).toBe("72.5 m²");
103
+ });
104
+
105
+ test("ein renderer.locale gewinnt weiterhin vor dem App-Locale", () => {
106
+ const screen: EntityListScreenDefinition = {
107
+ id: "",
108
+ type: "entityList",
109
+ entity: "__projection__",
110
+ columns: [{ field: "area", renderer: { format: "unit", unit: "m2", locale: "en-US" } }],
111
+ };
112
+ expect(renderAreaListUnderLocale("de-DE", 72.5, screen)).toBe("72.5 m²");
113
+ });
114
+ });
115
+
116
+ describe("RenderList — default number-column threads the app locale (fw#2437)", () => {
117
+ test("kein expliziter renderer: number-Zelle folgt dem App-Locale, nicht der Runtime-Locale", () => {
118
+ expect(renderAreaListUnderLocale("de-DE", 1234.5, numberScreen)).toBe(
119
+ new Intl.NumberFormat("de-DE").format(1234.5),
120
+ );
121
+ expect(renderAreaListUnderLocale("en-US", 1234.5, numberScreen)).toBe(
122
+ new Intl.NumberFormat("en-US").format(1234.5),
123
+ );
124
+ });
125
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
3
+ import { render, waitFor } from "@testing-library/react";
4
+ import { DocumentLangSync } from "../document-lang-sync";
5
+
6
+ function resolverWithLocale(initial: string): {
7
+ readonly resolver: LocaleResolver;
8
+ setLocale(next: string): void;
9
+ } {
10
+ let locale = initial;
11
+ const listeners = new Set<() => void>();
12
+ return {
13
+ resolver: {
14
+ translate: (key: string) => key,
15
+ locale: () => locale,
16
+ timeZone: () => "UTC",
17
+ subscribe: (listener: () => void) => {
18
+ listeners.add(listener);
19
+ return () => listeners.delete(listener);
20
+ },
21
+ },
22
+ setLocale(next: string) {
23
+ locale = next;
24
+ for (const listener of listeners) listener();
25
+ },
26
+ };
27
+ }
28
+
29
+ describe("DocumentLangSync", () => {
30
+ test("sets document.documentElement.lang from the resolver and updates on locale change", async () => {
31
+ const { resolver, setLocale } = resolverWithLocale("de-DE");
32
+ render(<DocumentLangSync resolver={resolver} />);
33
+
34
+ await waitFor(() => {
35
+ expect(document.documentElement.lang).toBe("de-DE");
36
+ });
37
+
38
+ setLocale("en-US");
39
+ await waitFor(() => {
40
+ expect(document.documentElement.lang).toBe("en-US");
41
+ });
42
+ });
43
+ });
@@ -17,6 +17,7 @@ import type {
17
17
  QualifiedContentCollection,
18
18
  TranslationsByLocale,
19
19
  } from "@cosmicdrift/kumiko-renderer";
20
+ import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
20
21
  import type { ComponentType, ReactNode } from "react";
21
22
 
22
23
  /** What a `navProvidersFromCollections` factory returns: the providers plus
@@ -97,7 +98,7 @@ export type ClientFeatureDefinition = {
97
98
  * a plain textarea when no clientFeature registered one for that
98
99
  * format. Same last-wins semantics as columnRenderers. Public component
99
100
  * contract: `{ value, onChange, variables, readOnly }`. */
100
- readonly contentEditors?: Readonly<Record<string, ContentEditorComponent>>;
101
+ readonly contentEditors?: Partial<Record<ContentEditorFormat, ContentEditorComponent>>;
101
102
 
102
103
  /** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
103
104
  * TreeNode mit target angeklickt wird, schlägt der EditorPanel das
@@ -1,9 +1,8 @@
1
- // Keeps document.documentElement.lang in sync with the active locale.
2
- // Screen readers and the browser's built-in translate offer both read it,
3
- // and @cosmicdrift/kumiko-dispatcher-live reads it back to tag outgoing
4
- // requests with X-Locale (see dispatcher-live/src/locale.ts). This is the
5
- // only way the dispatcher a separate, server-dep-free package with no
6
- // access to the resolver — learns the active language.
1
+ // Keeps document.documentElement.lang (+ dataset.kumikoLocale) in sync with
2
+ // the active locale. Screen readers and the browser's built-in translate
3
+ // offer read lang; dispatcher-live reads dataset.kumikoLocale for X-Locale
4
+ // (see dispatcher-live/src/locale.ts) so a static <html lang> cannot spoof
5
+ // the active UI language.
7
6
  //
8
7
  // Lives here, not in @cosmicdrift/kumiko-renderer: that package is DOM-free
9
8
  // by design (Renderer-Boundaries Guard enforces it — web/native both
@@ -14,11 +13,18 @@
14
13
  import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
15
14
  import { useEffect } from "react";
16
15
 
16
+ function syncDocumentLocale(locale: string): void {
17
+ document.documentElement.lang = locale;
18
+ // Dedicated marker for dispatcher-live — static <html lang> must not
19
+ // look like an active UI locale (kumiko-framework#2334).
20
+ document.documentElement.dataset["kumikoLocale"] = locale;
21
+ }
22
+
17
23
  export function DocumentLangSync({ resolver }: { readonly resolver: LocaleResolver }): null {
18
24
  useEffect(() => {
19
- document.documentElement.lang = resolver.locale();
25
+ syncDocumentLocale(resolver.locale());
20
26
  return resolver.subscribe(() => {
21
- document.documentElement.lang = resolver.locale();
27
+ syncDocumentLocale(resolver.locale());
22
28
  });
23
29
  }, [resolver]);
24
30
  return null;
@@ -6,9 +6,7 @@ function stubDispatcher(result: QueryResult<{ url?: string }>): Dispatcher {
6
6
  return { query: async () => result } as unknown as Dispatcher;
7
7
  }
8
8
 
9
- // Sole postWithDownload suite (unit + DOM). The former
10
- // src/__tests__/download.test.tsx duplicate was removed — this helper
11
- // already covers both bunfig (node) and bunfig.dom.toml (happy-dom):
9
+ // Covers both bunfig (node) and bunfig.dom.toml (happy-dom):
12
10
  // - node env: install a minimal fake window for the duration of the test,
13
11
  // then remove it again so later suites are unaffected.
14
12
  // - dom env: patch only location.assign on the real window — swapping the
@@ -56,6 +54,7 @@ describe("postWithDownload", () => {
56
54
  });
57
55
 
58
56
  test("passes the dispatcher error through when not successful", async () => {
57
+ tracked = trackAssignedUrl();
59
58
  const dispatcherError: DispatcherError = {
60
59
  code: "boom",
61
60
  httpStatus: 500,
@@ -65,6 +64,7 @@ describe("postWithDownload", () => {
65
64
  const dispatcher = stubDispatcher({ isSuccess: false, error: dispatcherError });
66
65
  const err = await postWithDownload(dispatcher, "files.signedUrl", {});
67
66
  expect(err).toEqual(dispatcherError);
67
+ expect(tracked.url()).toBeUndefined();
68
68
  });
69
69
 
70
70
  test("returns download_url_missing error when url is absent", async () => {
@@ -0,0 +1,30 @@
1
+ // happy-dom coverage for postWithDownload's window.location.assign path.
2
+ // bunfig.toml (unit `test`) ignores *.test.tsx; bunfig.dom.toml picks this up.
3
+
4
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
5
+ import type { Dispatcher, QueryResult } from "@cosmicdrift/kumiko-headless";
6
+ import { postWithDownload } from "../download";
7
+
8
+ function stubDispatcher(result: QueryResult<{ url?: string }>): Dispatcher {
9
+ return { query: async () => result } as unknown as Dispatcher;
10
+ }
11
+
12
+ describe("postWithDownload (happy-dom)", () => {
13
+ let assignSpy: ReturnType<typeof spyOn> | undefined;
14
+
15
+ afterEach(() => {
16
+ assignSpy?.mockRestore();
17
+ assignSpy = undefined;
18
+ });
19
+
20
+ test("assigns the url on the real window", async () => {
21
+ assignSpy = spyOn(window.location, "assign").mockImplementation(() => {});
22
+ const err = await postWithDownload(
23
+ stubDispatcher({ isSuccess: true, data: { url: "https://cdn.example.com/f.pdf" } }),
24
+ "files.signedUrl",
25
+ {},
26
+ );
27
+ expect(err).toBeNull();
28
+ expect(assignSpy).toHaveBeenCalledWith("https://cdn.example.com/f.pdf");
29
+ });
30
+ });
@@ -209,6 +209,19 @@ describe("defaultCellRender", () => {
209
209
  expect(new Intl.NumberFormat("de-DE").format(245.5)).toBe("245,5");
210
210
  });
211
211
 
212
+ test("number/decimal → explicit locale param wins over the runtime default (fw#2437)", () => {
213
+ expect(defaultCellRender(245.5, "number", undefined, "de-DE")).toBe(
214
+ new Intl.NumberFormat("de-DE").format(245.5),
215
+ );
216
+ expect(defaultCellRender(245.5, "decimal", undefined, "en-US")).toBe(
217
+ new Intl.NumberFormat("en-US").format(245.5),
218
+ );
219
+ // Same value, two locales → two different strings, not just "locale is accepted".
220
+ expect(defaultCellRender(245.5, "number", undefined, "de-DE")).not.toBe(
221
+ defaultCellRender(245.5, "number", undefined, "en-US"),
222
+ );
223
+ });
224
+
212
225
  test("money → { amount, currency } formatiert, kein [object Object]", () => {
213
226
  const result = defaultCellRender({ amount: 450, currency: "EUR" }, "money");
214
227
  expect(result).not.toBe("[object Object]");
@@ -254,4 +267,18 @@ describe("defaultCellRender", () => {
254
267
  expect(() => defaultCellRender({ amount: 1, currency: "" }, "money")).not.toThrow();
255
268
  expect(defaultCellRender({ amount: 1, currency: "" }, "money")).toBe("[object Object]");
256
269
  });
270
+
271
+ test("money → explicit locale param wins over guessLocale's navigator fallback (fw#2437)", () => {
272
+ const value = { amount: 1234.5, currency: "EUR" };
273
+ const de = defaultCellRender(value, "money", undefined, "de-DE");
274
+ const en = defaultCellRender(value, "money", undefined, "en-US");
275
+ // Same amount, two locales → two different strings, not just "locale is accepted".
276
+ expect(de).not.toBe(en);
277
+ expect(de).toBe(
278
+ new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5),
279
+ );
280
+ expect(en).toBe(
281
+ new Intl.NumberFormat("en-US", { style: "currency", currency: "EUR" }).format(1234.5),
282
+ );
283
+ });
257
284
  });
@@ -498,6 +498,27 @@ describe("EmbeddedListInput — paste", () => {
498
498
  });
499
499
 
500
500
  describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
501
+ test("Enter on a select trigger in the last cell does not append a row", () => {
502
+ const columns: readonly EmbeddedListColumn[] = [
503
+ {
504
+ field: "status",
505
+ label: "Status",
506
+ type: "select",
507
+ required: false,
508
+ derived: false,
509
+ options: ["open"],
510
+ },
511
+ ];
512
+ const rows = [{ status: "open" }];
513
+ const onAddRow = mock(() => {});
514
+ renderWithLocale(<EmbeddedListInput {...baseProps({ columns, rows, onAddRow })} />);
515
+ const desktop = within(screen.getByTestId("lines-desktop"));
516
+ const trigger = desktop.getByTestId("lines-cell-0-status").querySelector("button");
517
+ if (trigger === null) throw new Error("expected a select trigger button");
518
+ fireEvent.keyDown(trigger, { key: "Enter", code: "Enter" });
519
+ expect(onAddRow).not.toHaveBeenCalled();
520
+ });
521
+
501
522
  test("Enter on the last editable cell of the last row fires onAddRow and prevents default, same as Tab", () => {
502
523
  const rows = [{ description: "A", quantity: 1, amount: 100 }];
503
524
  const onAddRow = mock(() => {});
@@ -8,6 +8,7 @@
8
8
  // basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
9
9
  // Dropdown etc. kommen später).
10
10
 
11
+ import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
11
12
  import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
12
13
  import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
13
14
  import type {
@@ -36,6 +37,8 @@ import {
36
37
  type StepBarProps,
37
38
  type TextProps,
38
39
  useColumnRenderer,
40
+ useOptionalLocale,
41
+ useOptionalTranslation,
39
42
  useTranslation,
40
43
  type WizardStepGroupProps,
41
44
  WriteFailedError,
@@ -311,7 +314,7 @@ function DefaultField({
311
314
  // (nav-tree.tsx) — a separate, smaller registry instead of a shared
312
315
  // import, because field icons cover a different use case (email, phone,
313
316
  // location, …) than nav icons (dashboard, tables, …).
314
- const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
317
+ const FIELD_ICONS = {
315
318
  mail: Mail,
316
319
  lock: Lock,
317
320
  hash: Hash,
@@ -325,10 +328,12 @@ const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
325
328
  globe: Globe,
326
329
  key: KeyRound,
327
330
  "map-pin": MapPin,
328
- };
331
+ } as const satisfies Readonly<Record<FieldIconKey, typeof Mail>>;
329
332
 
330
- function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[string] | undefined {
331
- return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon) ? FIELD_ICONS[icon] : undefined;
333
+ function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[FieldIconKey] | undefined {
334
+ return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
335
+ ? FIELD_ICONS[icon as FieldIconKey]
336
+ : undefined;
332
337
  }
333
338
 
334
339
  // Wraps a text/number input with a left-positioned prefix icon when
@@ -668,6 +673,10 @@ function DefaultDataTable({
668
673
  getRowTestId,
669
674
  getCellTestId,
670
675
  }: DataTableProps): ReactNode {
676
+ // One locale/translate subscription per table — not per cell (fw#2345).
677
+ // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
678
+ const tableTranslate = useOptionalTranslation();
679
+ const tableLocale = useOptionalLocale();
671
680
  // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
672
681
  // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
673
682
  // scrollen typischerweise mit dem Page-Container, nicht intern.
@@ -734,12 +743,6 @@ function DefaultDataTable({
734
743
  // if the sum of the columns gets too wide.
735
744
  className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
736
745
  title={cellTitle(row.values[col.field])}
737
- // A click into the editable cell's widget must not also
738
- // trigger the row's onClick (typically "Open Detail") —
739
- // same reasoning as the actions cell below.
740
- {...(onCellChange !== undefined && {
741
- onClick: (e: MouseEvent) => e.stopPropagation(),
742
- })}
743
746
  >
744
747
  <DataTableCell
745
748
  value={row.values[col.field]}
@@ -747,6 +750,8 @@ function DefaultDataTable({
747
750
  field={col.field}
748
751
  type={col.type}
749
752
  renderer={col.renderer}
753
+ translate={tableTranslate}
754
+ locale={tableLocale}
750
755
  {...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
751
756
  {...(onCellChange !== undefined && {
752
757
  onChange: (value: unknown) => onCellChange(row.id, col.field, value),
@@ -1442,25 +1447,18 @@ export function defaultCellRender(
1442
1447
  value: unknown,
1443
1448
  type: string,
1444
1449
  optionLabels?: Readonly<Record<string, string>>,
1450
+ locale?: string,
1445
1451
  ): string {
1446
1452
  if (value === null || value === undefined || value === "") return "";
1447
1453
  if (type === "boolean") return value === true ? "✓" : "";
1448
- if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
1454
+ if (type === "timestamp" || type === "date") {
1455
+ return applyFormatSpec({ format: type, locale }, value);
1456
+ }
1449
1457
  if (type === "number" || type === "decimal" || type === "bigInt") {
1450
- // No `locale` param here for the same reason as the money branch below:
1451
- // DataTableCell has no app-locale context to thread through yet.
1452
- // Intl.NumberFormat(undefined) resolves the runtime's default locale.
1453
- return applyFormatSpec({ format: type }, value);
1458
+ return applyFormatSpec({ format: type, locale }, value);
1454
1459
  }
1455
1460
  if (type === "money") {
1456
1461
  if (!isMoneyValue(value)) return String(value);
1457
- // No `locale` param here: DataTableCell has no app-locale context to
1458
- // thread through (unlike the form path — see MoneyInput/RenderField in
1459
- // packages/renderer, which pins the LocaleProvider locale). formatMoney
1460
- // falls back to guessLocale() (navigator.language), so the same amount
1461
- // can render differently in a table vs. a form on the same screen.
1462
- // Mid-term: pass the app locale down here too, analogous to render-field.
1463
- //
1464
1462
  // formatMoney expects minor units scaled by currencyDecimals(currency).
1465
1463
  // rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
1466
1464
  // instead, which disagrees with currencyDecimals for non-2-decimal
@@ -1468,7 +1466,7 @@ export function defaultCellRender(
1468
1466
  // low). Deriving minor units from `amount` keeps this consistent with
1469
1467
  // render-field.tsx's moneyMinorValue.
1470
1468
  const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
1471
- return formatMoney(minor, value.currency);
1469
+ return formatMoney(minor, value.currency, locale);
1472
1470
  }
1473
1471
  if (type === "select") {
1474
1472
  const raw = String(value);
@@ -1508,6 +1506,8 @@ type DataTableCellProps = {
1508
1506
  readonly renderer?: unknown;
1509
1507
  readonly optionLabels?: Readonly<Record<string, string>>;
1510
1508
  readonly onChange?: (value: unknown) => void;
1509
+ readonly translate?: (key: string, params?: Readonly<Record<string, unknown>>) => string;
1510
+ readonly locale?: string;
1511
1511
  };
1512
1512
 
1513
1513
  // Cell-Renderer als Component (statt reiner Funktion) damit der
@@ -1527,12 +1527,17 @@ function DataTableCell({
1527
1527
  renderer,
1528
1528
  optionLabels,
1529
1529
  onChange,
1530
+ translate,
1531
+ locale,
1530
1532
  }: DataTableCellProps): ReactNode {
1531
1533
  const componentRef = isComponentRendererRef(renderer);
1532
1534
  const ResolvedComponent = useColumnRenderer(componentRef?.name);
1533
- const t = useTranslation();
1534
1535
  if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
1535
- return applyFormatSpec(renderer as { format: string } & Record<string, unknown>, value, t);
1536
+ return applyFormatSpec(
1537
+ { locale, ...(renderer as { format: string } & Record<string, unknown>) },
1538
+ value,
1539
+ translate,
1540
+ );
1536
1541
  }
1537
1542
  if (typeof renderer === "function") {
1538
1543
  const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
@@ -1540,7 +1545,7 @@ function DataTableCell({
1540
1545
  }
1541
1546
  if (componentRef !== undefined) {
1542
1547
  if (ResolvedComponent !== undefined) {
1543
- return (
1548
+ const node = (
1544
1549
  <ResolvedComponent
1545
1550
  value={value}
1546
1551
  row={row}
@@ -1548,6 +1553,21 @@ function DataTableCell({
1548
1553
  {...(onChange !== undefined && { onChange })}
1549
1554
  />
1550
1555
  );
1556
+ // stopPropagation only on the editable branch — plain cells must still
1557
+ // fire row onClick when onCellChange is set for sibling editable cells.
1558
+ if (onChange !== undefined) {
1559
+ return (
1560
+ // biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation only — not a control
1561
+ <span
1562
+ className="contents"
1563
+ onClick={(e: MouseEvent) => e.stopPropagation()}
1564
+ onKeyDown={(e) => e.stopPropagation()}
1565
+ >
1566
+ {node}
1567
+ </span>
1568
+ );
1569
+ }
1570
+ return node;
1551
1571
  }
1552
1572
  // Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
1553
1573
  // typischer Fall: clientFeatures.columnRenderers vergessen oder
@@ -1562,11 +1582,11 @@ function DataTableCell({
1562
1582
  // dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
1563
1583
  return (
1564
1584
  <Badge variant="outline" className="px-1.5 text-muted-foreground">
1565
- {defaultCellRender(value, type, optionLabels)}
1585
+ {defaultCellRender(value, type, optionLabels, locale)}
1566
1586
  </Badge>
1567
1587
  );
1568
1588
  }
1569
- return defaultCellRender(value, type, optionLabels);
1589
+ return defaultCellRender(value, type, optionLabels, locale);
1570
1590
  }
1571
1591
 
1572
1592
  // ---- Form + Section + Grid + Text ----
package/src/ui/sheet.tsx CHANGED
@@ -47,8 +47,6 @@ function SheetOverlay({
47
47
 
48
48
  function SheetContent({
49
49
  className,
50
- overlayClassName,
51
- overlayStyle,
52
50
  children,
53
51
  side = "right",
54
52
  showCloseButton = true,
@@ -56,12 +54,10 @@ function SheetContent({
56
54
  }: React.ComponentProps<typeof SheetPrimitive.Content> & {
57
55
  side?: "top" | "right" | "bottom" | "left"
58
56
  showCloseButton?: boolean
59
- overlayClassName?: string
60
- overlayStyle?: React.CSSProperties
61
57
  }) {
62
58
  return (
63
59
  <SheetPortal>
64
- <SheetOverlay className={overlayClassName} style={overlayStyle} />
60
+ <SheetOverlay />
65
61
  <SheetPrimitive.Content
66
62
  data-slot="sheet-content"
67
63
  className={cn(
@@ -104,16 +100,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
104
100
  return (
105
101
  <div
106
102
  data-slot="sheet-footer"
107
- // Matches the card footer convention (primitives/index.tsx cardFooter
108
- // + cardFooterBorder) for padding/border/button-row shape, but keeps
109
- // the panel's own `bg-background` instead of the card footer's
110
- // `bg-muted/30` — the sheet panel isn't a card, so its footer reads
111
- // as the same surface as the body above it. The border-t alone marks
112
- // the footer boundary.
113
- className={cn(
114
- "mt-auto flex items-center justify-end gap-2 border-t bg-background px-[var(--card-padding)] py-4",
115
- className,
116
- )}
103
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
117
104
  {...props}
118
105
  />
119
106
  )
@@ -18,6 +18,9 @@ describe("FeedList", () => {
18
18
  test("renders trailing content only when defined", () => {
19
19
  render(<FeedList rows={rows} testId="feed" />);
20
20
  expect(screen.getByText("3 min")).toBeTruthy();
21
+ const items = screen.getAllByRole("listitem");
22
+ expect(items[0]?.textContent).toContain("3 min");
23
+ expect(items[1]?.textContent).not.toContain("min");
21
24
  });
22
25
 
23
26
  test("renders the empty state when there are no rows", () => {
@@ -318,6 +318,33 @@ describe("StatusBarChart", () => {
318
318
  expect(container.querySelectorAll("rect").length).toBe(5);
319
319
  expect(screen.getByText("heute")).toBeTruthy();
320
320
  });
321
+
322
+ test("dense: fixed-size flat bars ohne Tick/Gradient, aria-Label + Tooltips bleiben", () => {
323
+ const { container } = render(
324
+ <StatusBarChart
325
+ dense
326
+ ariaLabel="Zahlungsmonate"
327
+ entries={[
328
+ { key: "m1", level: 1, tone: "ok", label: "Januar: bezahlt" },
329
+ { key: "m2", level: 0.5, tone: "bad", label: "Februar: offen" },
330
+ ]}
331
+ />,
332
+ );
333
+ const svg = container.querySelector("svg");
334
+ expect(screen.getByRole("img", { name: "Zahlungsmonate" })).toBeTruthy();
335
+ expect(svg?.getAttribute("class")).not.toContain("w-full");
336
+ expect(svg?.getAttribute("width")).not.toBeNull();
337
+ expect(container.querySelectorAll("linearGradient").length).toBe(0);
338
+ // 1 bar (non-last entry) + 1 bar + 1 last-highlight stripe (no tick) = 3 rects
339
+ expect(container.querySelectorAll("rect").length).toBe(3);
340
+ expect(container.querySelectorAll("title").length).toBe(3); // aria title + 2 entry tooltips
341
+ });
342
+
343
+ test("dense: leere Entries reservieren keine 36px-Höhe", () => {
344
+ const { container } = render(<StatusBarChart dense ariaLabel="Leer" entries={[]} />);
345
+ const placeholder = container.querySelector("div[aria-hidden]");
346
+ expect(placeholder?.className).toBe("h-3");
347
+ });
321
348
  });
322
349
 
323
350
  describe("TimeseriesChart", () => {
@@ -23,15 +23,17 @@ export type StatusBarEntry = {
23
23
  readonly label?: string;
24
24
  };
25
25
 
26
- /** Status-Balkenleiste (z.B. 90-Tage-Uptime): variable-height Bars mit
27
- * Gradient-Fade + Tick-Line am Bar-Top; der letzte Eintrag bekommt einen
28
- * „jetzt"-Accent-Stripe. */
26
+ /** Status bar strip (e.g. 90-day uptime): variable-height bars with
27
+ * gradient fade + tick line on top; the last entry gets a "now"
28
+ * accent stripe. `dense` swaps this for a flat-fill, fixed-size
29
+ * variant sized to fit a table cell instead of stretching to `w-full`. */
29
30
  export function StatusBarChart({
30
31
  entries,
31
32
  ariaLabel,
32
33
  startLabel,
33
34
  endLabel,
34
35
  highlightLast = true,
36
+ dense = false,
35
37
  testId,
36
38
  }: {
37
39
  readonly entries: readonly StatusBarEntry[];
@@ -40,28 +42,34 @@ export function StatusBarChart({
40
42
  readonly startLabel?: string;
41
43
  readonly endLabel?: string;
42
44
  readonly highlightLast?: boolean;
45
+ /** Compact variant for table cells: fixed intrinsic size (no `w-full` stretch), flat fills instead of gradients, no tick marks. */
46
+ readonly dense?: boolean;
43
47
  readonly testId?: string;
44
48
  }): ReactNode {
45
49
  const gradPrefix = useId();
46
- if (entries.length === 0) return <div className="h-9" aria-hidden />;
50
+ if (entries.length === 0) return <div className={dense ? "h-3" : "h-9"} aria-hidden />;
47
51
 
48
- const chartHeight = 36;
49
- const tickHeight = 1;
52
+ const chartHeight = dense ? 12 : 36;
53
+ const tickHeight = dense ? 0 : 1;
54
+ const barWidth = dense ? 3 : 1;
50
55
  const barGap = 1;
51
56
  const lastIdx = entries.length - 1;
57
+ const totalWidth = entries.length * (barWidth + barGap);
52
58
 
53
59
  return (
54
60
  <div data-testid={testId}>
55
61
  <svg
56
- viewBox={`0 0 ${entries.length * (1 + barGap)} ${chartHeight}`}
57
- preserveAspectRatio="none"
58
- className="block h-9 w-full"
62
+ viewBox={`0 0 ${totalWidth} ${chartHeight}`}
63
+ preserveAspectRatio={dense ? undefined : "none"}
64
+ width={dense ? totalWidth : undefined}
65
+ height={dense ? chartHeight : undefined}
66
+ className={dense ? "block" : "block h-9 w-full"}
59
67
  role="img"
60
68
  aria-label={ariaLabel}
61
69
  >
62
70
  <title>{ariaLabel}</title>
63
71
  {entries.map((entry, idx) => {
64
- const x = idx * (1 + barGap);
72
+ const x = idx * (barWidth + barGap);
65
73
  const level = Math.max(0, Math.min(1, entry.level));
66
74
  const barHeight = (chartHeight - tickHeight) * level;
67
75
  const barY = chartHeight - barHeight;
@@ -70,33 +78,44 @@ export function StatusBarChart({
70
78
  const gradId = `${gradPrefix}-${idx}`;
71
79
  return (
72
80
  <g key={entry.key}>
73
- <defs>
74
- <linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
75
- <stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
76
- <stop offset="100%" stopColor={color} stopOpacity={0.05} />
77
- </linearGradient>
78
- </defs>
81
+ {!dense && (
82
+ <defs>
83
+ <linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
84
+ <stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
85
+ <stop offset="100%" stopColor={color} stopOpacity={0.05} />
86
+ </linearGradient>
87
+ </defs>
88
+ )}
79
89
  {isLast && (
80
90
  <rect
81
- x={x - 0.5}
91
+ x={x - barGap / 2}
82
92
  y={0}
83
- width={2}
93
+ width={barWidth + barGap}
84
94
  height={chartHeight}
85
95
  fill="var(--color-foreground)"
86
96
  fillOpacity={0.06}
87
97
  />
88
98
  )}
89
- <rect x={x} y={barY} width={1} height={barHeight} fill={`url(#${gradId})`}>
90
- {entry.label !== undefined && <title>{entry.label}</title>}
91
- </rect>
92
99
  <rect
93
100
  x={x}
94
- y={barY - tickHeight}
95
- width={1}
96
- height={tickHeight}
97
- fill="var(--color-foreground)"
98
- fillOpacity={isLast ? 1.0 : 0.7}
99
- />
101
+ y={barY}
102
+ width={barWidth}
103
+ height={barHeight}
104
+ fill={dense ? color : `url(#${gradId})`}
105
+ fillOpacity={dense ? (isLast ? 1 : 0.75) : undefined}
106
+ >
107
+ {entry.label !== undefined && <title>{entry.label}</title>}
108
+ </rect>
109
+ {!dense && (
110
+ <rect
111
+ x={x}
112
+ y={barY - tickHeight}
113
+ width={barWidth}
114
+ height={tickHeight}
115
+ fill="var(--color-foreground)"
116
+ fillOpacity={isLast ? 1.0 : 0.7}
117
+ />
118
+ )}
100
119
  </g>
101
120
  );
102
121
  })}
@@ -4,14 +4,16 @@ import { type ReactNode, useRef, useState } from "react";
4
4
  import { clamp } from "../lib/clamp";
5
5
  import { cn } from "../lib/cn";
6
6
  import { useIsNarrowViewport } from "../primitives/use-narrow-viewport";
7
- import {
8
- Sheet,
9
- SheetContent,
10
- SheetDescription,
11
- SheetFooter,
12
- SheetHeader,
13
- SheetTitle,
14
- } from "../ui/sheet";
7
+ import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../ui/sheet";
8
+ import { DrawerSheetContent } from "./sheet-parts";
9
+
10
+ // Mirrors primitives/index.tsx's cardFooter + cardFooterBorder (row layout,
11
+ // end-justified actions, top border, panel surface instead of the card
12
+ // footer's bg-muted/30) — those two consts aren't exported, so the
13
+ // equivalent classes are inlined here rather than growing the vendored
14
+ // SheetFooter's own default className with panel-specific styling.
15
+ const DRAWER_FOOTER_CLASS =
16
+ "flex-row items-center justify-end border-t bg-background px-[var(--card-padding)] py-4";
15
17
 
16
18
  export type DrawerProps = {
17
19
  readonly open: boolean;
@@ -161,7 +163,7 @@ export function Drawer({
161
163
 
162
164
  return (
163
165
  <Sheet open={open} onOpenChange={onOpenChange}>
164
- <SheetContent
166
+ <DrawerSheetContent
165
167
  side={side}
166
168
  data-testid={testId}
167
169
  overlayStyle={overlayStyle}
@@ -193,7 +195,9 @@ export function Drawer({
193
195
  </SheetHeader>
194
196
  )}
195
197
  <div className="flex-1 overflow-y-auto px-4">{children}</div>
196
- {footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
198
+ {footer !== undefined && (
199
+ <SheetFooter className={DRAWER_FOOTER_CLASS}>{footer}</SheetFooter>
200
+ )}
197
201
  {canResize && !narrow && (
198
202
  // biome-ignore lint/a11y/useSemanticElements: <hr> can't carry pointer/keyboard drag interaction or a live width value — a draggable separator needs a div with the ARIA role.
199
203
  <div
@@ -214,7 +218,7 @@ export function Drawer({
214
218
  )}
215
219
  />
216
220
  )}
217
- </SheetContent>
221
+ </DrawerSheetContent>
218
222
  </Sheet>
219
223
  );
220
224
  }
@@ -0,0 +1,62 @@
1
+ import { XIcon } from "lucide-react";
2
+ import { Dialog as SheetPrimitive } from "radix-ui";
3
+ import type { ComponentProps, CSSProperties, ReactNode } from "react";
4
+ import { cn } from "../lib/cn";
5
+
6
+ // Drawer needs a per-instance overlay dim/blur (its `backdrop` prop), but
7
+ // ../ui/sheet.tsx is vendored shadcn (regenerated via scripts/sync-shadcn.ts,
8
+ // never hand-edited — #1965) and its SheetContent hard-codes a plain
9
+ // `<SheetOverlay />` with no way to reach the overlay's inline style. Rather
10
+ // than growing the vendored primitive an ad-hoc prop that a resync would
11
+ // silently drop, this composes the same Portal + Overlay + Content tree
12
+ // directly from the underlying radix-ui Dialog primitive (mirrors
13
+ // SheetContent's className logic 1:1 so Drawer's other side-variant
14
+ // behavior — slide animation, borders — stays identical).
15
+ export type DrawerSheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & {
16
+ readonly side?: "top" | "right" | "bottom" | "left";
17
+ readonly showCloseButton?: boolean;
18
+ readonly overlayStyle?: CSSProperties;
19
+ };
20
+
21
+ export function DrawerSheetContent({
22
+ className,
23
+ overlayStyle,
24
+ children,
25
+ side = "right",
26
+ showCloseButton = true,
27
+ ...props
28
+ }: DrawerSheetContentProps): ReactNode {
29
+ return (
30
+ <SheetPrimitive.Portal data-slot="sheet-portal">
31
+ <SheetPrimitive.Overlay
32
+ data-slot="sheet-overlay"
33
+ style={overlayStyle}
34
+ className="fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
35
+ />
36
+ <SheetPrimitive.Content
37
+ data-slot="sheet-content"
38
+ className={cn(
39
+ "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
40
+ side === "right" &&
41
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
42
+ side === "left" &&
43
+ "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
44
+ side === "top" &&
45
+ "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
46
+ side === "bottom" &&
47
+ "inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
48
+ className,
49
+ )}
50
+ {...props}
51
+ >
52
+ {children}
53
+ {showCloseButton && (
54
+ <SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
55
+ <XIcon className="size-4" />
56
+ <span className="sr-only">Close</span>
57
+ </SheetPrimitive.Close>
58
+ )}
59
+ </SheetPrimitive.Content>
60
+ </SheetPrimitive.Portal>
61
+ );
62
+ }
@@ -92,9 +92,10 @@ export function UploadZone({
92
92
 
93
93
  async function uploadOne(file: File): Promise<void> {
94
94
  const rowId = String(nextRowId.current++);
95
- setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
95
+ const uploadFile = await resizeImageBeforeUpload(file);
96
+ setRows((prev) => [...prev, { id: rowId, fileName: uploadFile.name, status: "uploading" }]);
96
97
  try {
97
- await onUpload(await resizeImageBeforeUpload(file));
98
+ await onUpload(uploadFile);
98
99
  setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
99
100
  } catch (cause) {
100
101
  const message = cause instanceof Error ? cause.message : t("kumiko.widget.upload.error");
@@ -107,6 +108,8 @@ export function UploadZone({
107
108
  async function uploadFiles(files: FileList | null): Promise<void> {
108
109
  if (files === null || files.length === 0) return;
109
110
  const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
111
+ // Reset before awaits so re-picking the same file during a slow upload still fires change.
112
+ if (inputRef.current) inputRef.current.value = "";
110
113
  const [accepted, rejected] = partitionByAccept(picked, accept);
111
114
  for (const file of rejected) {
112
115
  setRows((prev) => [
@@ -120,9 +123,6 @@ export function UploadZone({
120
123
  ]);
121
124
  }
122
125
  await Promise.all(accepted.map((file) => uploadOne(file)));
123
- // Reset so re-picking the SAME file still fires change — the browser
124
- // suppresses the event otherwise.
125
- if (inputRef.current) inputRef.current.value = "";
126
126
  }
127
127
 
128
128
  function handleDrop(e: DragEvent<HTMLLabelElement>): void {