@cosmicdrift/kumiko-renderer-web 0.190.0 → 0.191.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.190.0",
3
+ "version": "0.191.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.190.0",
20
- "@cosmicdrift/kumiko-headless": "0.190.0",
21
- "@cosmicdrift/kumiko-renderer": "0.190.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.191.0",
20
+ "@cosmicdrift/kumiko-headless": "0.191.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.191.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",
@@ -71,3 +71,66 @@ describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
71
71
  expect(screen.queryByTestId("field-fullName-errors")).toBeNull();
72
72
  });
73
73
  });
74
+
75
+ // fw#1966: the wizard chrome used to only show a "Step X of Y" counter —
76
+ // no way to see what the remaining steps are called. The step bar renders
77
+ // every section title up front and marks the current one.
78
+ describe("entityEdit wizard — step bar (fw#1966)", () => {
79
+ test("shows every section title as a step entry", () => {
80
+ renderWizard();
81
+
82
+ expect(screen.getByTestId("render-edit-wizard-steps-step-0").textContent).toContain("Step 1");
83
+ expect(screen.getByTestId("render-edit-wizard-steps-step-1").textContent).toContain("Step 2");
84
+ });
85
+
86
+ test("marks the active step and moves the marker forward on Next", async () => {
87
+ const { container } = renderWizard();
88
+
89
+ expect(screen.getByTestId("render-edit-wizard-steps-step-0").getAttribute("aria-current")).toBe(
90
+ "step",
91
+ );
92
+ expect(
93
+ screen.getByTestId("render-edit-wizard-steps-step-1").getAttribute("aria-current"),
94
+ ).toBeNull();
95
+
96
+ const fullNameInput = container.querySelector("#kumiko-edit-fullName");
97
+ await userEvent.type(fullNameInput as Element, "Ada Lovelace");
98
+ await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
99
+
100
+ expect(
101
+ screen.getByTestId("render-edit-wizard-steps-step-0").getAttribute("aria-current"),
102
+ ).toBeNull();
103
+ expect(screen.getByTestId("render-edit-wizard-steps-step-1").getAttribute("aria-current")).toBe(
104
+ "step",
105
+ );
106
+ });
107
+
108
+ test("a wizard with N sections renders N step entries", () => {
109
+ const threeStepScreen: EntityEditScreenDefinition = {
110
+ id: "profile-edit-3",
111
+ type: "entityEdit",
112
+ entity: "profile",
113
+ layout: {
114
+ mode: "wizard",
115
+ sections: [
116
+ { title: "Basics", fields: ["fullName"] },
117
+ { title: "Contact", fields: ["email"] },
118
+ { title: "Review", fields: [] },
119
+ ],
120
+ },
121
+ };
122
+ const threeStepSchema: FeatureSchema = {
123
+ featureName: "demo",
124
+ entities: { profile: profileEntity },
125
+ screens: [threeStepScreen],
126
+ };
127
+ const { container } = render(
128
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
129
+ <KumikoScreen schema={threeStepSchema} qn="demo:screen:profile-edit-3" />
130
+ </DispatcherProvider>,
131
+ );
132
+
133
+ const steps = container.querySelectorAll('[data-testid^="render-edit-wizard-steps-step-"]');
134
+ expect(steps.length).toBe(3);
135
+ });
136
+ });
package/src/index.ts CHANGED
@@ -224,6 +224,7 @@ export {
224
224
  StatCard,
225
225
  StatusBadge,
226
226
  StatusBarChart,
227
+ StepBar,
227
228
  smoothPath,
228
229
  TextareaField,
229
230
  TextField,
@@ -33,6 +33,7 @@ import {
33
33
  type LinkProps,
34
34
  type ProgressProps,
35
35
  type SectionProps,
36
+ type StepBarProps,
36
37
  type TextProps,
37
38
  useColumnRenderer,
38
39
  useTranslation,
@@ -82,6 +83,7 @@ import { Label as UiLabel } from "../ui/label";
82
83
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
83
84
  import { Textarea } from "../ui/textarea";
84
85
  import { ProgressBar } from "../widgets/progress-bar";
86
+ import { StepBar } from "../widgets/step-bar";
85
87
  import { ComboboxInput } from "./combobox";
86
88
  import { DateInput } from "./date-input";
87
89
  import { DefaultDialog } from "./dialog";
@@ -1899,6 +1901,24 @@ function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
1899
1901
  return <ProgressBar value={value} testId={testId} />;
1900
1902
  }
1901
1903
 
1904
+ function DefaultStepBar({
1905
+ steps,
1906
+ currentIndex,
1907
+ compactLabel,
1908
+ testId,
1909
+ compactTestId,
1910
+ }: StepBarProps): ReactNode {
1911
+ return (
1912
+ <StepBar
1913
+ steps={steps}
1914
+ currentIndex={currentIndex}
1915
+ compactLabel={compactLabel}
1916
+ testId={testId}
1917
+ compactTestId={compactTestId}
1918
+ />
1919
+ );
1920
+ }
1921
+
1902
1922
  function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
1903
1923
  // Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
1904
1924
  // Section-Heading = h2 mit uppercase + muted-foreground — derselbe
@@ -1993,4 +2013,5 @@ export const defaultPrimitives: CorePrimitives = {
1993
2013
  ConfigCascadeView: DefaultConfigCascadeView,
1994
2014
  Link: DefaultLink,
1995
2015
  Progress: DefaultProgress,
2016
+ StepBar: DefaultStepBar,
1996
2017
  };
@@ -9,6 +9,7 @@ import { SectionCard } from "../section-card";
9
9
  import { MiniStat, StatCard } from "../stat";
10
10
  import { EmptyState } from "../states";
11
11
  import { StatusBadge } from "../status-badge";
12
+ import { StepBar } from "../step-bar";
12
13
 
13
14
  describe("StatusBadge", () => {
14
15
  test("rendert Label mit Tone-Klassen", () => {
@@ -55,6 +56,82 @@ describe("ProgressBar", () => {
55
56
  });
56
57
  });
57
58
 
59
+ describe("StepBar", () => {
60
+ test("rendert einen Step-Eintrag pro Label mit sichtbarem Titel", () => {
61
+ render(
62
+ <StepBar
63
+ steps={["Basics", "Industry", "Review"]}
64
+ currentIndex={0}
65
+ compactLabel="Step 1 of 3 · Basics"
66
+ testId="steps"
67
+ />,
68
+ );
69
+ expect(screen.getByTestId("steps-step-0").textContent).toContain("Basics");
70
+ expect(screen.getByTestId("steps-step-1").textContent).toContain("Industry");
71
+ expect(screen.getByTestId("steps-step-2").textContent).toContain("Review");
72
+ });
73
+
74
+ test("markiert nur den aktiven Schritt via aria-current", () => {
75
+ render(
76
+ <StepBar
77
+ steps={["Basics", "Industry", "Review"]}
78
+ currentIndex={1}
79
+ compactLabel="Step 2 of 3 · Industry"
80
+ testId="steps"
81
+ />,
82
+ );
83
+ expect(screen.getByTestId("steps-step-0").getAttribute("aria-current")).toBeNull();
84
+ expect(screen.getByTestId("steps-step-1").getAttribute("aria-current")).toBe("step");
85
+ expect(screen.getByTestId("steps-step-2").getAttribute("aria-current")).toBeNull();
86
+ });
87
+
88
+ test("erledigte Schritte zeigen ein Häkchen statt der Nummer, kommende ihre Nummer", () => {
89
+ render(
90
+ <StepBar
91
+ steps={["Basics", "Industry", "Review"]}
92
+ currentIndex={1}
93
+ compactLabel="Step 2 of 3 · Industry"
94
+ testId="steps"
95
+ />,
96
+ );
97
+ const done = screen.getByTestId("steps-step-0");
98
+ expect(done.querySelector("svg")).not.toBeNull();
99
+ expect(done.textContent).not.toContain("1");
100
+
101
+ const current = screen.getByTestId("steps-step-1");
102
+ expect(current.querySelector("svg")).toBeNull();
103
+ expect(current.textContent).toContain("2");
104
+
105
+ const upcoming = screen.getByTestId("steps-step-2");
106
+ expect(upcoming.querySelector("svg")).toBeNull();
107
+ expect(upcoming.textContent).toContain("3");
108
+ });
109
+
110
+ test("erledigte Schritte bleiben für Screenreader als erledigt erkennbar", () => {
111
+ render(
112
+ <StepBar
113
+ steps={["Basics", "Industry"]}
114
+ currentIndex={1}
115
+ compactLabel="Step 2 of 2 · Industry"
116
+ testId="steps"
117
+ />,
118
+ );
119
+ expect(screen.getByTestId("steps-step-0").textContent).toContain("Done");
120
+ });
121
+
122
+ test("rendert den compactLabel-Fallback für schmale Viewports", () => {
123
+ render(
124
+ <StepBar
125
+ steps={["Basics", "Industry"]}
126
+ currentIndex={0}
127
+ compactLabel="Step 1 of 2 · Basics"
128
+ compactTestId="steps-compact"
129
+ />,
130
+ );
131
+ expect(screen.getByTestId("steps-compact").textContent).toBe("Step 1 of 2 · Basics");
132
+ });
133
+ });
134
+
58
135
  describe("ModeSwitch", () => {
59
136
  test("markiert aktive Option und feuert onChange", () => {
60
137
  const onChange = mock((_v: string) => {});
@@ -56,5 +56,6 @@ export { SectionCard } from "./section-card";
56
56
  export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
57
57
  export { EmptyState, ErrorState, LoadingState } from "./states";
58
58
  export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
59
+ export { StepBar } from "./step-bar";
59
60
  export { UploadZone, type UploadZoneProps } from "./upload-zone";
60
61
  export { useDraft } from "./use-draft";
@@ -0,0 +1,68 @@
1
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { Check } from "lucide-react";
3
+ import type { ReactNode } from "react";
4
+ import { cn } from "../lib/cn";
5
+
6
+ /** Wizard step overview — numbered chips with a connector line between
7
+ * them, not clickable. Three visual states, none conveyed by color alone:
8
+ * done (checkmark replaces the number, `aria-current` absent, a sr-only
9
+ * label says so since the number itself is gone), current (`aria-current
10
+ * ="step"`, own background), upcoming (dimmed, number visible). Below
11
+ * `sm` the chip row hides in favor of `compactLabel` (seven step names
12
+ * don't fit on a phone) — both live in the DOM, Tailwind's
13
+ * `hidden`/`sm:hidden` pair picks the visible one per viewport (same
14
+ * pattern as embedded-list-input.tsx's desktop/mobile split). */
15
+ export function StepBar({
16
+ steps,
17
+ currentIndex,
18
+ compactLabel,
19
+ testId,
20
+ compactTestId,
21
+ }: {
22
+ readonly steps: readonly string[];
23
+ readonly currentIndex: number;
24
+ readonly compactLabel: string;
25
+ readonly testId?: string;
26
+ readonly compactTestId?: string;
27
+ }): ReactNode {
28
+ const t = useTranslation();
29
+ return (
30
+ <>
31
+ <ol data-testid={testId} className="hidden items-center gap-2 sm:flex sm:flex-wrap">
32
+ {steps.map((label, i) => {
33
+ const isDone = i < currentIndex;
34
+ const isCurrent = i === currentIndex;
35
+ return (
36
+ // biome-ignore lint/suspicious/noArrayIndexKey: steps is a static, positional list — index is stable identity, no reorder/DnD.
37
+ <li key={`${i}-${label}`} className="flex items-center gap-2">
38
+ {i > 0 && <span aria-hidden="true" className="h-px w-4 bg-border" />}
39
+ <span
40
+ aria-current={isCurrent ? "step" : undefined}
41
+ data-testid={testId !== undefined ? `${testId}-step-${i}` : undefined}
42
+ className={cn(
43
+ "flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
44
+ isCurrent && "bg-primary font-semibold text-primary-foreground",
45
+ isDone && "text-primary",
46
+ !isCurrent && !isDone && "text-muted-foreground",
47
+ )}
48
+ >
49
+ {isDone ? (
50
+ <>
51
+ <Check aria-hidden="true" className="size-3.5" />
52
+ <span className="sr-only">{t("kumiko.widget.stepBar.done")}</span>
53
+ </>
54
+ ) : (
55
+ <span className="text-xs font-semibold">{i + 1}</span>
56
+ )}
57
+ {label}
58
+ </span>
59
+ </li>
60
+ );
61
+ })}
62
+ </ol>
63
+ <p data-testid={compactTestId} className="text-sm text-muted-foreground sm:hidden">
64
+ {compactLabel}
65
+ </p>
66
+ </>
67
+ );
68
+ }