@cosmicdrift/kumiko-renderer-web 0.196.0 → 0.197.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.196.0",
3
+ "version": "0.197.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.196.0",
20
- "@cosmicdrift/kumiko-headless": "0.196.0",
21
- "@cosmicdrift/kumiko-renderer": "0.196.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.197.0",
20
+ "@cosmicdrift/kumiko-headless": "0.197.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.197.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",
@@ -492,6 +492,48 @@ describe("DataTable", () => {
492
492
  expect(screen.queryByTestId("dt-pager-page-60")).not.toBeNull();
493
493
  expect(screen.queryByTestId("dt-pager-page-30")).not.toBeNull();
494
494
  });
495
+
496
+ test("Default-Locale (en): Status-Text + aria-Labels aus i18n-Fallback statt hartcodiert", () => {
497
+ render(
498
+ <DataTable
499
+ columns={cols}
500
+ rows={oneRow}
501
+ testId="dt"
502
+ pager={{ page: 1, limit: 50, total: 3000, onPageChange: mock() }}
503
+ />,
504
+ );
505
+ expect(screen.getByTestId("dt-pager-status").textContent).toBe("1–50 of 3,000");
506
+ expect(screen.getByTestId("dt-pager-prev").getAttribute("aria-label")).toBe("Previous page");
507
+ expect(screen.getByTestId("dt-pager-next").getAttribute("aria-label")).toBe("Next page");
508
+ expect(screen.getByTestId("dt-pager-page-2").getAttribute("aria-label")).toBe("Page 2");
509
+ });
510
+
511
+ test("de-Locale: Status-Text + aria-Labels kommen aus i18n statt hartcodiertem Englisch (issue #2007)", async () => {
512
+ const { LocaleProvider, createStaticLocaleResolver, kumikoDefaultTranslations } =
513
+ await import("@cosmicdrift/kumiko-renderer");
514
+ render(
515
+ <LocaleProvider
516
+ resolver={createStaticLocaleResolver({ locale: "de" })}
517
+ fallbackBundles={[kumikoDefaultTranslations]}
518
+ >
519
+ <DataTable
520
+ columns={cols}
521
+ rows={oneRow}
522
+ testId="dt"
523
+ pager={{ page: 1, limit: 50, total: 3000, onPageChange: mock() }}
524
+ />
525
+ </LocaleProvider>,
526
+ );
527
+ // toLocaleString() without a locale arg stays runtime-default (not the
528
+ // resolver locale) — out of scope for #2007, which only addresses the
529
+ // untranslated word/aria-labels.
530
+ expect(screen.getByTestId("dt-pager-status").textContent).toBe("1–50 von 3,000");
531
+ expect(screen.getByTestId("dt-pager-prev").getAttribute("aria-label")).toBe(
532
+ "Vorherige Seite",
533
+ );
534
+ expect(screen.getByTestId("dt-pager-next").getAttribute("aria-label")).toBe("Nächste Seite");
535
+ expect(screen.getByTestId("dt-pager-page-2").getAttribute("aria-label")).toBe("Seite 2");
536
+ });
495
537
  });
496
538
 
497
539
  // Infinite-scroll sentinel: renders a sentinel div, shows a spinner when
@@ -1153,11 +1153,15 @@ function InfiniteSentinel({
1153
1153
  );
1154
1154
  }
1155
1155
 
1156
- // Pager — klassischer Page-Pager (← 1 … N →) für DataTables mit
1157
- // pagination="pages". Layout: Status-Text links ("X – Y of Z"),
1158
- // Page-Buttons mittig, Prev/Next pfeile außen. Window-of-7 zeigt nicht
1159
- // alle Pages bei großen Listen der User sieht den aktuellen Bereich
1160
- // + first/last als Anchor.
1156
+ // Pager — classic page pager (← 1 … N →) for DataTables with
1157
+ // pagination="pages". Layout: status text on the left ("X – Y of Z"),
1158
+ // page buttons in the middle, prev/next arrows on the outside. The
1159
+ // window-of-7 doesn't show all pages for large lists the user sees
1160
+ // the current range plus first/last as anchors.
1161
+ //
1162
+ // Status text and prev/next/page aria-labels go through t(...), which
1163
+ // requires a LocaleProvider in the tree — same requirement as
1164
+ // InfiniteSentinel above, already established for DataTable consumers.
1161
1165
  function Pager({
1162
1166
  page,
1163
1167
  limit,
@@ -1171,6 +1175,7 @@ function Pager({
1171
1175
  readonly onPageChange: (next: number) => void;
1172
1176
  readonly testId?: string;
1173
1177
  }): ReactNode {
1178
+ const t = useTranslation();
1174
1179
  const totalPages = Math.max(1, Math.ceil(total / limit));
1175
1180
  const safePage = Math.max(1, Math.min(page, totalPages));
1176
1181
  const from = (safePage - 1) * limit + 1;
@@ -1183,11 +1188,15 @@ function Pager({
1183
1188
  className="flex items-center justify-between mt-3 gap-3 text-sm text-muted-foreground"
1184
1189
  >
1185
1190
  <div data-testid={testId !== undefined ? `${testId}-status` : undefined}>
1186
- {from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}
1191
+ {t("kumiko.pager.status", {
1192
+ from: from.toLocaleString(),
1193
+ to: to.toLocaleString(),
1194
+ total: total.toLocaleString(),
1195
+ })}
1187
1196
  </div>
1188
1197
  <div className="flex items-center gap-1">
1189
1198
  <PagerButton
1190
- ariaLabel="Previous page"
1199
+ ariaLabel={t("kumiko.pager.previousPage")}
1191
1200
  disabled={safePage <= 1}
1192
1201
  onClick={() => onPageChange(safePage - 1)}
1193
1202
  testId={testId !== undefined ? `${testId}-prev` : undefined}
@@ -1206,7 +1215,7 @@ function Pager({
1206
1215
  ) : (
1207
1216
  <PagerButton
1208
1217
  key={entry}
1209
- ariaLabel={`Page ${entry}`}
1218
+ ariaLabel={t("kumiko.pager.page", { entry })}
1210
1219
  ariaCurrent={entry === safePage ? "page" : undefined}
1211
1220
  active={entry === safePage}
1212
1221
  onClick={() => onPageChange(entry)}
@@ -1217,7 +1226,7 @@ function Pager({
1217
1226
  ),
1218
1227
  )}
1219
1228
  <PagerButton
1220
- ariaLabel="Next page"
1229
+ ariaLabel={t("kumiko.pager.nextPage")}
1221
1230
  disabled={safePage >= totalPages}
1222
1231
  onClick={() => onPageChange(safePage + 1)}
1223
1232
  testId={testId !== undefined ? `${testId}-next` : undefined}
@@ -1755,7 +1764,11 @@ function DefaultSection({
1755
1764
  <section
1756
1765
  data-testid={testId}
1757
1766
  className={cn(
1758
- "flex flex-col gap-4 px-6 py-6",
1767
+ // py-4 (not the standalone card's py-6): the border-t between
1768
+ // sections already carries the section break, so the vertical
1769
+ // gap only needs to read as roughly double the gap-4 field row
1770
+ // spacing, not triple it.
1771
+ "flex flex-col gap-4 px-6 py-4",
1759
1772
  variant === "destructive" && "border-l-2 border-destructive/40",
1760
1773
  )}
1761
1774
  >
package/src/ui/sheet.tsx CHANGED
@@ -105,10 +105,13 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
105
105
  <div
106
106
  data-slot="sheet-footer"
107
107
  // Matches the card footer convention (primitives/index.tsx cardFooter
108
- // + cardFooterBorder) same padding/border/button-row shape as
109
- // SectionCard/DefaultCard so a drawer footer reads like a card footer.
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.
110
113
  className={cn(
111
- "mt-auto flex items-center justify-end gap-2 border-t bg-muted/30 px-[var(--card-padding)] py-4",
114
+ "mt-auto flex items-center justify-end gap-2 border-t bg-background px-[var(--card-padding)] py-4",
112
115
  className,
113
116
  )}
114
117
  {...props}
@@ -33,6 +33,35 @@ describe("Drawer", () => {
33
33
  expect(onOpenChange).toHaveBeenCalledWith(false);
34
34
  });
35
35
 
36
+ describe("showCloseButton", () => {
37
+ test("default true: Close-Button im Header vorhanden", () => {
38
+ render(
39
+ <Drawer open={true} onOpenChange={() => {}} title="Mail" testId="drawer">
40
+ <div>Body</div>
41
+ </Drawer>,
42
+ );
43
+ expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
44
+ });
45
+
46
+ test("false: kein Close-Button, Titel und Maximieren bleiben", () => {
47
+ render(
48
+ <Drawer
49
+ open={true}
50
+ onOpenChange={() => {}}
51
+ title="Mail"
52
+ testId="drawer"
53
+ showCloseButton={false}
54
+ resize={{}}
55
+ >
56
+ <div>Body</div>
57
+ </Drawer>,
58
+ );
59
+ expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
60
+ expect(screen.getByText("Mail")).toBeTruthy();
61
+ expect(screen.getByRole("button", { name: /maximize drawer width/i })).toBeTruthy();
62
+ });
63
+ });
64
+
36
65
  describe("backdrop", () => {
37
66
  function getOverlay(): HTMLElement {
38
67
  const overlay = document.querySelector('[data-slot="sheet-overlay"]');
@@ -122,18 +151,18 @@ describe("Drawer", () => {
122
151
  );
123
152
  expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("800");
124
153
  });
125
- test("Startbreite folgt max(520px, 25vw) wenn resize.defaultWidthPx fehlt", () => {
126
- withViewportWidth(3000, () => {
154
+ test("Startbreite folgt max(600px, 37.5vw) wenn resize.defaultWidthPx fehlt", () => {
155
+ withViewportWidth(1920, () => {
127
156
  render(
128
157
  <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
129
158
  <div>Body</div>
130
159
  </Drawer>,
131
160
  );
132
- expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("750");
161
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("720");
133
162
  });
134
163
  });
135
164
 
136
- test("Startbreite clamped auf effectiveMaxWidthPx wenn 25vw ueber MAX_WIDTH_PX hinauslaeuft", () => {
165
+ test("Startbreite clamped auf effectiveMaxWidthPx wenn 37.5vw ueber MAX_WIDTH_PX hinauslaeuft", () => {
137
166
  withViewportWidth(8000, () => {
138
167
  render(
139
168
  <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
@@ -166,14 +195,14 @@ describe("Drawer", () => {
166
195
  });
167
196
  });
168
197
 
169
- test("Startbreite clamped auf 520px Minimum bei schmalem Viewport", () => {
198
+ test("Startbreite clamped auf 600px Minimum bei schmalem Viewport", () => {
170
199
  withViewportWidth(800, () => {
171
200
  render(
172
201
  <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
173
202
  <div>Body</div>
174
203
  </Drawer>,
175
204
  );
176
- expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("520");
205
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("600");
177
206
  });
178
207
  });
179
208
 
@@ -21,6 +21,10 @@ export type DrawerProps = {
21
21
  readonly footer?: ReactNode;
22
22
  readonly children: ReactNode;
23
23
  readonly testId?: string;
24
+ /** Header close (X). Default `true`. Turn off when the caller's own
25
+ * footer already has a dedicated close/cancel action, so there is only
26
+ * one way to dismiss the drawer. */
27
+ readonly showCloseButton?: boolean;
24
28
  /** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
25
29
  readonly resize?: {
26
30
  readonly defaultWidthPx?: number;
@@ -37,10 +41,18 @@ export type DrawerProps = {
37
41
 
38
42
  const MIN_WIDTH_PX = 320;
39
43
  const MAX_WIDTH_PX = 1000;
40
- // Must match the static `max(520px,25vw)` in floatingSideClass — Tailwind's
44
+ // Must match the static `max(600px,37.5vw)` in floatingSideClass — Tailwind's
41
45
  // JIT can't read these at runtime, so the two are kept in sync by hand.
42
- const DEFAULT_WIDTH_MIN_PX = 520;
43
- const DEFAULT_WIDTH_VIEWPORT_RATIO = 0.25;
46
+ // 600px (vs. the old 520px floor) gives a two-column field row (e.g.
47
+ // street/number, zip/city) room to breathe, while staying well under half
48
+ // of a 1280px viewport so a narrow window isn't dominated by the drawer.
49
+ // 37.5vw (vs. the old 25vw) keeps that same 600px on a 1280–1600px window
50
+ // (25vw was still under the floor there, making the ratio dead weight up
51
+ // to 2400px) but actually grows past the floor by ~1600px, so a 1920px
52
+ // window — where the two-column row is cramped just as often — lands
53
+ // around 720px instead of being stuck at 600px alongside a 1280px window.
54
+ const DEFAULT_WIDTH_MIN_PX = 600;
55
+ const DEFAULT_WIDTH_VIEWPORT_RATIO = 0.375;
44
56
  const DEFAULT_BLUR_PX = 0;
45
57
  const DEFAULT_DIM_PERCENT = 20;
46
58
 
@@ -55,13 +67,13 @@ function defaultWidthFromViewport(): number {
55
67
  function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
56
68
  switch (side) {
57
69
  case "left":
58
- return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
70
+ return "inset-y-8 left-8 h-auto w-[max(600px,37.5vw)] max-w-[85vw] sm:max-w-[max(600px,37.5vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
59
71
  case "top":
60
72
  return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
61
73
  case "bottom":
62
74
  return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
63
75
  default:
64
- return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
76
+ return "inset-y-8 right-8 h-auto w-[max(600px,37.5vw)] max-w-[85vw] sm:max-w-[max(600px,37.5vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
65
77
  }
66
78
  }
67
79
 
@@ -77,6 +89,7 @@ export function Drawer({
77
89
  footer,
78
90
  children,
79
91
  testId,
92
+ showCloseButton = true,
80
93
  resize,
81
94
  backdrop,
82
95
  }: DrawerProps): ReactNode {
@@ -146,6 +159,7 @@ export function Drawer({
146
159
  side={side}
147
160
  data-testid={testId}
148
161
  overlayStyle={overlayStyle}
162
+ showCloseButton={showCloseButton}
149
163
  className={floatingSideClass(side)}
150
164
  style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
151
165
  >