@cosmicdrift/kumiko-renderer-web 0.138.0 → 0.140.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.138.0",
3
+ "version": "0.140.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.138.0",
20
- "@cosmicdrift/kumiko-headless": "0.138.0",
21
- "@cosmicdrift/kumiko-renderer": "0.138.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.140.0",
20
+ "@cosmicdrift/kumiko-headless": "0.140.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.140.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",
package/src/index.ts CHANGED
@@ -159,10 +159,12 @@ export type {
159
159
  ComparisonMetric,
160
160
  DateFieldProps,
161
161
  FeedRow,
162
+ FileFieldProps,
162
163
  NumberFieldProps,
163
164
  ProgressListRow,
164
165
  QueryTableColumn,
165
166
  QueryTableProps,
167
+ RangeFieldProps,
166
168
  ResultColumn,
167
169
  SelectFieldProps,
168
170
  StatDelta,
@@ -182,6 +184,7 @@ export {
182
184
  EmptyState,
183
185
  ErrorState,
184
186
  FeedList,
187
+ FileField,
185
188
  LoadingState,
186
189
  MiniStat,
187
190
  ModeSwitch,
@@ -191,6 +194,7 @@ export {
191
194
  ProgressBar,
192
195
  ProgressList,
193
196
  QueryTable,
197
+ RangeField,
194
198
  ResultPanel,
195
199
  ResultTable,
196
200
  SectionCard,
@@ -111,15 +111,30 @@ const BUTTON_VARIANT = {
111
111
  link: "link",
112
112
  } as const;
113
113
 
114
+ const BUTTON_SIZE = {
115
+ sm: "sm",
116
+ md: "default",
117
+ icon: "icon",
118
+ } as const;
119
+
114
120
  function DefaultButton({
115
121
  type = "button",
116
122
  onClick,
117
123
  disabled,
118
124
  loading,
119
125
  variant = "primary",
126
+ size = "md",
127
+ ariaLabel,
128
+ fullWidth,
120
129
  children,
121
130
  testId,
122
131
  }: ButtonProps): ReactNode {
132
+ // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
133
+ // gepolsterte Fläche; fullWidth streckt CTA-Buttons in Karten/Panels.
134
+ const className =
135
+ [variant === "link" ? "h-auto px-0 py-0" : "", fullWidth === true ? "w-full" : ""]
136
+ .filter(Boolean)
137
+ .join(" ") || undefined;
123
138
  return (
124
139
  <UiButton
125
140
  type={type}
@@ -128,9 +143,9 @@ function DefaultButton({
128
143
  data-testid={testId}
129
144
  data-loading={loading === true ? "true" : undefined}
130
145
  variant={BUTTON_VARIANT[variant]}
131
- // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner),
132
- // nicht als gepolsterte Buttonfläche.
133
- className={variant === "link" ? "h-auto px-0 py-0" : undefined}
146
+ size={BUTTON_SIZE[size]}
147
+ {...(ariaLabel !== undefined && { "aria-label": ariaLabel })}
148
+ className={className}
134
149
  >
135
150
  {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
136
151
  </UiButton>
@@ -292,6 +307,19 @@ function DefaultInput(props: InputProps): ReactNode {
292
307
  className="text-right tabular-nums"
293
308
  />
294
309
  );
310
+ case "range":
311
+ return (
312
+ <input
313
+ type="range"
314
+ {...common}
315
+ min={props.min}
316
+ max={props.max}
317
+ step={props.step ?? 1}
318
+ value={props.value}
319
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(Number(e.target.value))}
320
+ className="w-full accent-primary"
321
+ />
322
+ );
295
323
  case "boolean":
296
324
  return (
297
325
  <Checkbox
@@ -1,12 +1,16 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
2
4
  import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
3
5
  import { DetailList } from "../detail-list";
4
6
  import {
5
7
  BooleanField,
6
8
  DateField,
9
+ FileField,
7
10
  MoneyField,
8
11
  NumberField,
9
12
  PercentField,
13
+ RangeField,
10
14
  SelectField,
11
15
  TextareaField,
12
16
  TextField,
@@ -185,6 +189,77 @@ describe("ResultPanel footer", () => {
185
189
  });
186
190
  });
187
191
 
192
+ describe("RangeField", () => {
193
+ test("meldet den Slider-Wert als Zahl", () => {
194
+ const onChange = mock();
195
+ render(
196
+ <RangeField
197
+ label="Abruf"
198
+ id="r"
199
+ name="r"
200
+ value={20}
201
+ min={0}
202
+ max={100}
203
+ step={5}
204
+ onChange={onChange}
205
+ />,
206
+ );
207
+ expect(screen.getByText("Abruf")).toBeTruthy();
208
+ fireEvent.change(screen.getByRole("slider"), { target: { value: "45" } });
209
+ expect(onChange).toHaveBeenCalledWith(45);
210
+ });
211
+ });
212
+
213
+ describe("FileField", () => {
214
+ test("rendert Label", () => {
215
+ render(
216
+ <FileField label="Bild" id="f" name="f" value={null} onChange={() => {}} variant="image" />,
217
+ );
218
+ expect(screen.getByText("Bild")).toBeTruthy();
219
+ });
220
+ });
221
+
222
+ describe("Button size", () => {
223
+ function SizedButton({ size }: { readonly size: "sm" | "md" | "icon" }): ReactNode {
224
+ const { Button } = usePrimitives();
225
+ return (
226
+ <Button size={size} onClick={() => {}}>
227
+ X
228
+ </Button>
229
+ );
230
+ }
231
+ test("size=sm rendert die kompakte shadcn-Größe (h-8)", () => {
232
+ render(<SizedButton size="sm" />);
233
+ expect(screen.getByRole("button", { name: "X" }).className).toContain("h-8");
234
+ });
235
+
236
+ test("ariaLabel gibt icon-only-Buttons einen zugänglichen Namen", () => {
237
+ function IconBtn(): ReactNode {
238
+ const { Button } = usePrimitives();
239
+ return (
240
+ <Button size="icon" ariaLabel="Entfernen" onClick={() => {}}>
241
+
242
+ </Button>
243
+ );
244
+ }
245
+ render(<IconBtn />);
246
+ expect(screen.getByRole("button", { name: "Entfernen" })).toBeTruthy();
247
+ });
248
+
249
+ test("fullWidth streckt den Button (w-full)", () => {
250
+ function WideBtn(): ReactNode {
251
+ const { Button } = usePrimitives();
252
+ return (
253
+ <Button fullWidth onClick={() => {}}>
254
+ Upgrade
255
+ </Button>
256
+ );
257
+ }
258
+ render(<WideBtn />);
259
+ expect(screen.getByRole("button", { name: "Upgrade" }).className).toContain("w-full");
260
+ });
261
+ });
262
+
188
263
  describe("ComparisonTable", () => {
189
264
  test("transponierte Matrix mit Best-Highlight je Kennzahl", () => {
190
265
  const cols = [
@@ -241,3 +241,83 @@ export function TextareaField({
241
241
  </Field>
242
242
  );
243
243
  }
244
+
245
+ export interface RangeFieldProps extends FieldBase {
246
+ readonly value: number;
247
+ readonly onChange: (v: number) => void;
248
+ readonly min: number;
249
+ readonly max: number;
250
+ readonly step?: number;
251
+ }
252
+
253
+ /** Schieberegler = Field + Input(kind:"range"). */
254
+ export function RangeField({
255
+ label,
256
+ id,
257
+ name,
258
+ value,
259
+ onChange,
260
+ min,
261
+ max,
262
+ step,
263
+ required,
264
+ disabled,
265
+ testId,
266
+ }: RangeFieldProps): ReactNode {
267
+ const { Field, Input } = usePrimitives();
268
+ return (
269
+ <Field id={id} label={label} required={required} testId={testId}>
270
+ <Input
271
+ kind="range"
272
+ id={id}
273
+ name={name}
274
+ value={value}
275
+ onChange={onChange}
276
+ min={min}
277
+ max={max}
278
+ required={required}
279
+ disabled={disabled}
280
+ {...(step !== undefined && { step })}
281
+ />
282
+ </Field>
283
+ );
284
+ }
285
+
286
+ export interface FileFieldProps extends FieldBase {
287
+ /** FileRef-UUID der gespeicherten Datei, oder null. */
288
+ readonly value: string | null;
289
+ readonly onChange: (fileId: string | null) => void;
290
+ readonly accept?: readonly string[];
291
+ /** "image" zeigt eine Vorschau, "file" nur den Dateinamen. Default "file". */
292
+ readonly variant?: "file" | "image";
293
+ }
294
+
295
+ /** Datei-Upload = Field + Input(kind:"file"|"image") — FileRef-basiert. */
296
+ export function FileField({
297
+ label,
298
+ id,
299
+ name,
300
+ value,
301
+ onChange,
302
+ accept,
303
+ variant = "file",
304
+ required,
305
+ disabled,
306
+ testId,
307
+ }: FileFieldProps): ReactNode {
308
+ const { Field, Input } = usePrimitives();
309
+ return (
310
+ <Field id={id} label={label} required={required} testId={testId}>
311
+ <Input
312
+ kind={variant}
313
+ id={id}
314
+ name={name}
315
+ value={value}
316
+ onChange={onChange}
317
+ required={required}
318
+ disabled={disabled}
319
+ {...(accept !== undefined && { accept })}
320
+ />
321
+ </Field>
322
+ );
323
+ }
@@ -18,10 +18,14 @@ export {
18
18
  type BooleanFieldProps,
19
19
  DateField,
20
20
  type DateFieldProps,
21
+ FileField,
22
+ type FileFieldProps,
21
23
  MoneyField,
22
24
  NumberField,
23
25
  type NumberFieldProps,
24
26
  PercentField,
27
+ RangeField,
28
+ type RangeFieldProps,
25
29
  SelectField,
26
30
  type SelectFieldProps,
27
31
  TextareaField,