@cosmicdrift/kumiko-renderer-web 0.189.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 +4 -4
- package/src/__tests__/wizard-form-validation.test.tsx +63 -0
- package/src/index.ts +1 -0
- package/src/primitives/index.tsx +21 -0
- package/src/ui/sheet.tsx +10 -2
- package/src/widgets/__tests__/widgets.test.tsx +88 -0
- package/src/widgets/drawer.tsx +118 -2
- package/src/widgets/index.ts +1 -0
- package/src/widgets/progress-bar.tsx +5 -2
- 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.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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
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";
|
|
@@ -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
|
};
|
package/src/ui/sheet.tsx
CHANGED
|
@@ -47,6 +47,7 @@ function SheetOverlay({
|
|
|
47
47
|
|
|
48
48
|
function SheetContent({
|
|
49
49
|
className,
|
|
50
|
+
overlayClassName,
|
|
50
51
|
children,
|
|
51
52
|
side = "right",
|
|
52
53
|
showCloseButton = true,
|
|
@@ -54,10 +55,11 @@ function SheetContent({
|
|
|
54
55
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
55
56
|
side?: "top" | "right" | "bottom" | "left"
|
|
56
57
|
showCloseButton?: boolean
|
|
58
|
+
overlayClassName?: string
|
|
57
59
|
}) {
|
|
58
60
|
return (
|
|
59
61
|
<SheetPortal>
|
|
60
|
-
<SheetOverlay />
|
|
62
|
+
<SheetOverlay className={overlayClassName} />
|
|
61
63
|
<SheetPrimitive.Content
|
|
62
64
|
data-slot="sheet-content"
|
|
63
65
|
className={cn(
|
|
@@ -100,7 +102,13 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
100
102
|
return (
|
|
101
103
|
<div
|
|
102
104
|
data-slot="sheet-footer"
|
|
103
|
-
|
|
105
|
+
// Matches the card footer convention (primitives/index.tsx cardFooter
|
|
106
|
+
// + cardFooterBorder) — same padding/border/button-row shape as
|
|
107
|
+
// SectionCard/DefaultCard so a drawer footer reads like a card footer.
|
|
108
|
+
className={cn(
|
|
109
|
+
"mt-auto flex items-center justify-end gap-2 border-t bg-muted/30 px-[var(--card-padding)] py-4",
|
|
110
|
+
className,
|
|
111
|
+
)}
|
|
104
112
|
{...props}
|
|
105
113
|
/>
|
|
106
114
|
)
|
|
@@ -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", () => {
|
|
@@ -42,6 +43,93 @@ describe("ProgressBar", () => {
|
|
|
42
43
|
render(<ProgressBar value={-3} testId="bar" />);
|
|
43
44
|
expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("0");
|
|
44
45
|
});
|
|
46
|
+
|
|
47
|
+
test("Füll-Element bildet den Wert über Breite ab und erbt die Höhe nicht vom Elternteil", () => {
|
|
48
|
+
render(<ProgressBar value={0.5} testId="bar" />);
|
|
49
|
+
const bar = screen.getByTestId("bar");
|
|
50
|
+
const fill = bar.firstElementChild as HTMLElement;
|
|
51
|
+
expect(fill.style.width).toBe("50%");
|
|
52
|
+
expect(fill.className).not.toContain("h-full");
|
|
53
|
+
expect(fill.className).toContain("absolute");
|
|
54
|
+
expect(fill.className).toContain("inset-y-0");
|
|
55
|
+
expect(bar.className).toContain("relative");
|
|
56
|
+
});
|
|
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
|
+
});
|
|
45
133
|
});
|
|
46
134
|
|
|
47
135
|
describe("ModeSwitch", () => {
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
2
4
|
import {
|
|
3
5
|
Sheet,
|
|
4
6
|
SheetContent,
|
|
@@ -17,8 +19,40 @@ export type DrawerProps = {
|
|
|
17
19
|
readonly footer?: ReactNode;
|
|
18
20
|
readonly children: ReactNode;
|
|
19
21
|
readonly testId?: string;
|
|
22
|
+
/** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
|
|
23
|
+
readonly resizable?: boolean;
|
|
24
|
+
readonly defaultWidthPx?: number;
|
|
25
|
+
readonly minWidthPx?: number;
|
|
26
|
+
readonly maxWidthPx?: number;
|
|
20
27
|
};
|
|
21
28
|
|
|
29
|
+
const DEFAULT_WIDTH_PX = 420;
|
|
30
|
+
const MIN_WIDTH_PX = 320;
|
|
31
|
+
const MAX_WIDTH_PX = 800;
|
|
32
|
+
|
|
33
|
+
// Floating panel with a clearly visible margin on every edge, rounded on
|
|
34
|
+
// all four corners — replaces the sheet primitive's flush-to-viewport-edge
|
|
35
|
+
// per-side classes. twMerge resolves each utility group against the base
|
|
36
|
+
// (inset/width/height/border/rounding), so this fully overrides rather than
|
|
37
|
+
// stacking with it. 32px margin + 32px radius so the detachment from the
|
|
38
|
+
// viewport edge reads clearly at a glance, not just on close 1:1 inspection.
|
|
39
|
+
function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
|
|
40
|
+
switch (side) {
|
|
41
|
+
case "left":
|
|
42
|
+
return "inset-y-8 left-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] rounded-[2rem] border shadow-2xl";
|
|
43
|
+
case "top":
|
|
44
|
+
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
45
|
+
case "bottom":
|
|
46
|
+
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
47
|
+
default:
|
|
48
|
+
return "inset-y-8 right-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] rounded-[2rem] border shadow-2xl";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clamp(value: number, min: number, max: number): number {
|
|
53
|
+
return Math.min(Math.max(value, min), max);
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
23
57
|
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
24
58
|
* so screens skip per-route Radix boilerplate. */
|
|
@@ -31,10 +65,72 @@ export function Drawer({
|
|
|
31
65
|
footer,
|
|
32
66
|
children,
|
|
33
67
|
testId,
|
|
68
|
+
resizable = false,
|
|
69
|
+
defaultWidthPx = DEFAULT_WIDTH_PX,
|
|
70
|
+
minWidthPx = MIN_WIDTH_PX,
|
|
71
|
+
maxWidthPx = MAX_WIDTH_PX,
|
|
34
72
|
}: DrawerProps): ReactNode {
|
|
73
|
+
const canResize = resizable && (side === "left" || side === "right");
|
|
74
|
+
const [width, setWidth] = useState(defaultWidthPx);
|
|
75
|
+
const [maximized, setMaximized] = useState(false);
|
|
76
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
77
|
+
|
|
78
|
+
const effectiveMaxWidthPx = () =>
|
|
79
|
+
typeof window === "undefined"
|
|
80
|
+
? maxWidthPx
|
|
81
|
+
: Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
|
|
82
|
+
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
|
|
83
|
+
|
|
84
|
+
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
85
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
86
|
+
dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
|
|
87
|
+
setMaximized(false);
|
|
88
|
+
};
|
|
89
|
+
const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
90
|
+
if (dragRef.current === null) return;
|
|
91
|
+
const deltaX = event.clientX - dragRef.current.startX;
|
|
92
|
+
const signedDelta = side === "right" ? -deltaX : deltaX;
|
|
93
|
+
setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
|
|
94
|
+
};
|
|
95
|
+
const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
96
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
97
|
+
dragRef.current = null;
|
|
98
|
+
};
|
|
99
|
+
const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
100
|
+
const step = event.shiftKey ? 40 : 16;
|
|
101
|
+
const grow = side === "right" ? "ArrowLeft" : "ArrowRight";
|
|
102
|
+
const shrink = side === "right" ? "ArrowRight" : "ArrowLeft";
|
|
103
|
+
if (event.key !== grow && event.key !== shrink) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
setMaximized(false);
|
|
106
|
+
const delta = event.key === grow ? step : -step;
|
|
107
|
+
setWidth((current) => clamp(current + delta, minWidthPx, effectiveMaxWidthPx()));
|
|
108
|
+
};
|
|
109
|
+
|
|
35
110
|
return (
|
|
36
111
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
37
|
-
<SheetContent
|
|
112
|
+
<SheetContent
|
|
113
|
+
side={side}
|
|
114
|
+
data-testid={testId}
|
|
115
|
+
overlayClassName="bg-black/20 backdrop-blur-[2px]"
|
|
116
|
+
className={floatingSideClass(side)}
|
|
117
|
+
style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
|
|
118
|
+
>
|
|
119
|
+
{canResize && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => setMaximized((m) => !m)}
|
|
123
|
+
aria-pressed={maximized}
|
|
124
|
+
aria-label={maximized ? "Restore drawer width" : "Maximize drawer width"}
|
|
125
|
+
className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
|
|
126
|
+
>
|
|
127
|
+
{maximized ? (
|
|
128
|
+
<Minimize2Icon className="size-4" />
|
|
129
|
+
) : (
|
|
130
|
+
<Maximize2Icon className="size-4" />
|
|
131
|
+
)}
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
38
134
|
{(title !== undefined || description !== undefined) && (
|
|
39
135
|
<SheetHeader>
|
|
40
136
|
{title !== undefined && <SheetTitle>{title}</SheetTitle>}
|
|
@@ -43,6 +139,26 @@ export function Drawer({
|
|
|
43
139
|
)}
|
|
44
140
|
<div className="flex-1 overflow-y-auto px-4">{children}</div>
|
|
45
141
|
{footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
|
|
142
|
+
{canResize && (
|
|
143
|
+
// 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.
|
|
144
|
+
<div
|
|
145
|
+
role="separator"
|
|
146
|
+
aria-orientation="vertical"
|
|
147
|
+
aria-label="Resize drawer"
|
|
148
|
+
aria-valuenow={effectiveWidthPx}
|
|
149
|
+
aria-valuemin={minWidthPx}
|
|
150
|
+
aria-valuemax={effectiveMaxWidthPx()}
|
|
151
|
+
tabIndex={0}
|
|
152
|
+
onPointerDown={onHandlePointerDown}
|
|
153
|
+
onPointerMove={onHandlePointerMove}
|
|
154
|
+
onPointerUp={onHandlePointerUp}
|
|
155
|
+
onKeyDown={onHandleKeyDown}
|
|
156
|
+
className={cn(
|
|
157
|
+
"absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border",
|
|
158
|
+
side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2",
|
|
159
|
+
)}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
46
162
|
</SheetContent>
|
|
47
163
|
</Sheet>
|
|
48
164
|
);
|
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";
|
|
@@ -19,9 +19,12 @@ export function ProgressBar({
|
|
|
19
19
|
aria-valuenow={Math.round(pct * 100)}
|
|
20
20
|
aria-valuemin={0}
|
|
21
21
|
aria-valuemax={100}
|
|
22
|
-
className={cn("h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
22
|
+
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
23
23
|
>
|
|
24
|
-
<div
|
|
24
|
+
<div
|
|
25
|
+
className="absolute inset-y-0 left-0 rounded-full bg-primary"
|
|
26
|
+
style={{ width: `${pct * 100}%` }}
|
|
27
|
+
/>
|
|
25
28
|
</div>
|
|
26
29
|
);
|
|
27
30
|
}
|
|
@@ -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
|
+
}
|