@cosmicdrift/kumiko-renderer-web 0.190.0 → 0.192.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 +4 -4
- package/src/__tests__/wizard-form-validation.test.tsx +63 -0
- package/src/index.ts +1 -0
- package/src/primitives/__tests__/file-upload.test.tsx +25 -0
- package/src/primitives/file-upload.tsx +11 -5
- package/src/primitives/index.tsx +22 -0
- package/src/widgets/__tests__/widgets.test.tsx +77 -0
- package/src/widgets/index.ts +1 -0
- package/src/widgets/progress-bar.tsx +21 -12
- package/src/widgets/step-bar.tsx +68 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.192.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.192.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.192.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.192.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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { FileUploadInput } from "../file-upload";
|
|
4
|
+
|
|
5
|
+
const noop = () => {};
|
|
6
|
+
|
|
7
|
+
// alt="" makes the <img> presentational (no accessible role), so
|
|
8
|
+
// getByRole("img") wouldn't find it — query the DOM directly instead.
|
|
9
|
+
describe("FileUploadInput — image variant preview src (#1950)", () => {
|
|
10
|
+
test("with imageVariant set, the <img> src requests the named variant", () => {
|
|
11
|
+
const { container } = render(
|
|
12
|
+
<FileUploadInput kind="image" id="avatar" value="abc" onChange={noop} imageVariant="thumb" />,
|
|
13
|
+
);
|
|
14
|
+
expect(container.querySelector("img")?.getAttribute("src")).toBe(
|
|
15
|
+
"/api/files/abc/variant/thumb",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("without imageVariant, the <img> src loads the original", () => {
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<FileUploadInput kind="image" id="avatar" value="abc" onChange={noop} />,
|
|
22
|
+
);
|
|
23
|
+
expect(container.querySelector("img")?.getAttribute("src")).toBe("/api/files/abc");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// File/
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// File/image upload widget for the auto-edit form. Uploads via multipart POST
|
|
2
|
+
// to /api/files (mounted by the stack when a storageProvider is set), gets the
|
|
3
|
+
// FileRef UUID back and lifts it up as the field value. Image → round avatar
|
|
4
|
+
// preview (GET /api/files/:id, or the declared variant), file → file name.
|
|
5
5
|
|
|
6
6
|
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
7
7
|
import { ImageIcon, Loader2, Upload } from "lucide-react";
|
|
@@ -17,6 +17,7 @@ export type FileUploadInputProps = {
|
|
|
17
17
|
readonly disabled?: boolean;
|
|
18
18
|
readonly entityType?: string;
|
|
19
19
|
readonly fieldName?: string;
|
|
20
|
+
readonly imageVariant?: string;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
// "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
|
|
@@ -34,6 +35,7 @@ export function FileUploadInput({
|
|
|
34
35
|
disabled,
|
|
35
36
|
entityType,
|
|
36
37
|
fieldName,
|
|
38
|
+
imageVariant,
|
|
37
39
|
}: FileUploadInputProps): ReactNode {
|
|
38
40
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
41
|
const [uploading, setUploading] = useState(false);
|
|
@@ -79,7 +81,11 @@ export function FileUploadInput({
|
|
|
79
81
|
{kind === "image" &&
|
|
80
82
|
(value !== null ? (
|
|
81
83
|
<img
|
|
82
|
-
src={
|
|
84
|
+
src={
|
|
85
|
+
imageVariant !== undefined
|
|
86
|
+
? `/api/files/${value}/variant/${imageVariant}`
|
|
87
|
+
: `/api/files/${value}`
|
|
88
|
+
}
|
|
83
89
|
alt=""
|
|
84
90
|
className="size-16 rounded-full border object-cover"
|
|
85
91
|
/>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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";
|
|
@@ -431,6 +433,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
431
433
|
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
432
434
|
{...(props.entityType !== undefined && { entityType: props.entityType })}
|
|
433
435
|
{...(props.fieldName !== undefined && { fieldName: props.fieldName })}
|
|
436
|
+
{...(props.imageVariant !== undefined && { imageVariant: props.imageVariant })}
|
|
434
437
|
/>
|
|
435
438
|
);
|
|
436
439
|
case "date":
|
|
@@ -1899,6 +1902,24 @@ function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
|
|
|
1899
1902
|
return <ProgressBar value={value} testId={testId} />;
|
|
1900
1903
|
}
|
|
1901
1904
|
|
|
1905
|
+
function DefaultStepBar({
|
|
1906
|
+
steps,
|
|
1907
|
+
currentIndex,
|
|
1908
|
+
compactLabel,
|
|
1909
|
+
testId,
|
|
1910
|
+
compactTestId,
|
|
1911
|
+
}: StepBarProps): ReactNode {
|
|
1912
|
+
return (
|
|
1913
|
+
<StepBar
|
|
1914
|
+
steps={steps}
|
|
1915
|
+
currentIndex={currentIndex}
|
|
1916
|
+
compactLabel={compactLabel}
|
|
1917
|
+
testId={testId}
|
|
1918
|
+
compactTestId={compactTestId}
|
|
1919
|
+
/>
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1902
1923
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1903
1924
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1904
1925
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -1993,4 +2014,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1993
2014
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1994
2015
|
Link: DefaultLink,
|
|
1995
2016
|
Progress: DefaultProgress,
|
|
2017
|
+
StepBar: DefaultStepBar,
|
|
1996
2018
|
};
|
|
@@ -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) => {});
|
package/src/widgets/index.ts
CHANGED
|
@@ -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";
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Progress bar, `value` 0..1 (clamped).
|
|
6
|
+
*
|
|
7
|
+
* The track lives in its own inner element so a parent that pads or
|
|
8
|
+
* stretches its direct children (e.g. RenderEdit's wizard chrome) never
|
|
9
|
+
* touches the track's own `h-2` — a caller's `className` reaches the
|
|
10
|
+
* outer wrapper, not the height-bearing track.
|
|
11
|
+
*/
|
|
5
12
|
export function ProgressBar({
|
|
6
13
|
value,
|
|
7
14
|
className,
|
|
@@ -13,18 +20,20 @@ export function ProgressBar({
|
|
|
13
20
|
}): ReactNode {
|
|
14
21
|
const pct = Math.max(0, Math.min(1, value));
|
|
15
22
|
return (
|
|
16
|
-
<div
|
|
17
|
-
data-testid={testId}
|
|
18
|
-
role="progressbar"
|
|
19
|
-
aria-valuenow={Math.round(pct * 100)}
|
|
20
|
-
aria-valuemin={0}
|
|
21
|
-
aria-valuemax={100}
|
|
22
|
-
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
23
|
-
>
|
|
23
|
+
<div className={cn("w-full", className)}>
|
|
24
24
|
<div
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
data-testid={testId}
|
|
26
|
+
role="progressbar"
|
|
27
|
+
aria-valuenow={Math.round(pct * 100)}
|
|
28
|
+
aria-valuemin={0}
|
|
29
|
+
aria-valuemax={100}
|
|
30
|
+
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
31
|
+
>
|
|
32
|
+
<div
|
|
33
|
+
className="absolute inset-y-0 left-0 rounded-full bg-primary"
|
|
34
|
+
style={{ width: `${pct * 100}%` }}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
28
37
|
</div>
|
|
29
38
|
);
|
|
30
39
|
}
|
|
@@ -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
|
+
}
|