@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.234.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 +5 -5
- package/src/__tests__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +182 -3
- package/src/__tests__/projection-detail-record-layout.test.tsx +91 -0
- package/src/__tests__/render-edit.test.tsx +9 -10
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +84 -1
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +210 -17
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
|
@@ -129,6 +129,69 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
129
129
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
|
|
130
130
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
test("colors the status badge via the StatusTone heuristic for a recognized value", async () => {
|
|
134
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
135
|
+
...baseScreen,
|
|
136
|
+
header: { title: "tenantName", status: "state" },
|
|
137
|
+
};
|
|
138
|
+
const dispatcher = dispatcherReturning({ ...rowData, state: "overdue" });
|
|
139
|
+
|
|
140
|
+
render(
|
|
141
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
142
|
+
<KumikoScreen
|
|
143
|
+
schema={schemaFor(headerScreen)}
|
|
144
|
+
qn="rentals:screen:rent-detail"
|
|
145
|
+
entityId="rent-1"
|
|
146
|
+
/>
|
|
147
|
+
</DispatcherProvider>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
151
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
|
|
152
|
+
"text-status-bad",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("an unmapped status value falls back to the muted tone", async () => {
|
|
157
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
158
|
+
...baseScreen,
|
|
159
|
+
header: { title: "tenantName", status: "state" },
|
|
160
|
+
};
|
|
161
|
+
const dispatcher = dispatcherReturning({ ...rowData, state: "archived" });
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
165
|
+
<KumikoScreen
|
|
166
|
+
schema={schemaFor(headerScreen)}
|
|
167
|
+
qn="rentals:screen:rent-detail"
|
|
168
|
+
entityId="rent-1"
|
|
169
|
+
/>
|
|
170
|
+
</DispatcherProvider>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
174
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
|
|
175
|
+
"text-muted-foreground",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("read-only detail screens render no footer action bar", async () => {
|
|
180
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
181
|
+
|
|
182
|
+
render(
|
|
183
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
184
|
+
<KumikoScreen
|
|
185
|
+
schema={schemaFor(baseScreen)}
|
|
186
|
+
qn="rentals:screen:rent-detail"
|
|
187
|
+
entityId="rent-1"
|
|
188
|
+
/>
|
|
189
|
+
</DispatcherProvider>,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
193
|
+
expect(screen.queryByTestId("render-edit-form-actions")).toBeNull();
|
|
194
|
+
});
|
|
132
195
|
});
|
|
133
196
|
|
|
134
197
|
describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
|
|
@@ -399,6 +462,30 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
|
399
462
|
|
|
400
463
|
expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
|
|
401
464
|
});
|
|
465
|
+
|
|
466
|
+
test("relatedList tab content renders without its own Section card wrapper", async () => {
|
|
467
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
468
|
+
const { navApi } = navWithTab("payments");
|
|
469
|
+
|
|
470
|
+
render(
|
|
471
|
+
<NavProvider value={navApi}>
|
|
472
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
473
|
+
<KumikoScreen
|
|
474
|
+
schema={schemaFor(tabsScreen)}
|
|
475
|
+
qn="rentals:screen:rent-detail"
|
|
476
|
+
entityId="rent-1"
|
|
477
|
+
/>
|
|
478
|
+
</DispatcherProvider>
|
|
479
|
+
</NavProvider>,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
await waitFor(() =>
|
|
483
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
484
|
+
);
|
|
485
|
+
// hideTitle (tabs mode) drops the Section wrapper — DataTable already
|
|
486
|
+
// draws its own card frame, so a second nested Card would double it up.
|
|
487
|
+
expect(screen.queryByTestId("related-list-Payments")).toBeNull();
|
|
488
|
+
});
|
|
402
489
|
});
|
|
403
490
|
|
|
404
491
|
// Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
|
|
@@ -448,6 +535,10 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
|
|
|
448
535
|
// Contrast for the tabs-mode "form title suppressed" test above — outside
|
|
449
536
|
// tabs mode (hideSectionTitles unset) the form's own title still renders.
|
|
450
537
|
expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
|
|
538
|
+
// Contrast for the tabs-mode "no Section wrapper" test above — a stacked
|
|
539
|
+
// (non-tabs) relatedList section has a visible title, so it keeps its
|
|
540
|
+
// own Section card.
|
|
541
|
+
expect(screen.getByTestId("related-list-Payments")).toBeTruthy();
|
|
451
542
|
});
|
|
452
543
|
});
|
|
453
544
|
|
|
@@ -119,8 +119,8 @@ describe("RenderEdit", () => {
|
|
|
119
119
|
// notes is hidden (isUrgent=false): title, count, isUrgent → 3 cells.
|
|
120
120
|
expect(grid?.children.length).toBe(3);
|
|
121
121
|
|
|
122
|
-
const
|
|
123
|
-
fireEvent.click(
|
|
122
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
123
|
+
fireEvent.click(urgentSwitch as HTMLElement);
|
|
124
124
|
|
|
125
125
|
// notes becomes visible → 4 cells, no leftover empty cell from before.
|
|
126
126
|
expect(grid?.children.length).toBe(4);
|
|
@@ -240,10 +240,9 @@ describe("RenderEdit", () => {
|
|
|
240
240
|
);
|
|
241
241
|
|
|
242
242
|
expect(screen.queryByTestId("field-notes")).toBeNull();
|
|
243
|
-
// boolean
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
fireEvent.click(urgentCheckbox as HTMLElement);
|
|
243
|
+
// boolean field in a standard form = vendored Radix Switch → button[role=switch].
|
|
244
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
245
|
+
fireEvent.click(urgentSwitch as HTMLElement);
|
|
247
246
|
expect(screen.queryByTestId("field-notes")).toBeTruthy();
|
|
248
247
|
});
|
|
249
248
|
|
|
@@ -284,7 +283,7 @@ describe("RenderEdit", () => {
|
|
|
284
283
|
expect(seenResults[0]?.isSuccess).toBe(true);
|
|
285
284
|
});
|
|
286
285
|
|
|
287
|
-
test("layout.width defaults the form shell to max-w-
|
|
286
|
+
test("layout.width defaults the form shell to max-w-4xl when unset", () => {
|
|
288
287
|
render(
|
|
289
288
|
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
290
289
|
<RenderEdit<TestValues>
|
|
@@ -298,7 +297,7 @@ describe("RenderEdit", () => {
|
|
|
298
297
|
);
|
|
299
298
|
|
|
300
299
|
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
301
|
-
expect(shell?.className).toContain("max-w-
|
|
300
|
+
expect(shell?.className).toContain("max-w-4xl");
|
|
302
301
|
expect(shell?.className).not.toContain("max-w-3xl");
|
|
303
302
|
});
|
|
304
303
|
|
|
@@ -2858,8 +2857,8 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
2858
2857
|
expect((titleInput as HTMLInputElement).disabled).toBe(true);
|
|
2859
2858
|
const countInput = screen.getByTestId("field-count").querySelector("input");
|
|
2860
2859
|
expect((countInput as HTMLInputElement).disabled).toBe(true);
|
|
2861
|
-
const
|
|
2862
|
-
expect((
|
|
2860
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
2861
|
+
expect((urgentSwitch as HTMLButtonElement).disabled).toBe(true);
|
|
2863
2862
|
expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
|
|
2864
2863
|
});
|
|
2865
2864
|
|
|
@@ -440,13 +440,12 @@ describe("WorkspaceShell", () => {
|
|
|
440
440
|
expect(screen.queryByText("Tours")).toBeNull();
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
-
test("fill
|
|
443
|
+
test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
|
|
444
444
|
renderShell(
|
|
445
445
|
<WorkspaceShell
|
|
446
446
|
brand={<div>Brand</div>}
|
|
447
447
|
schema={schema}
|
|
448
448
|
user={{ id: "u1", roles: ["admin"] }}
|
|
449
|
-
fill
|
|
450
449
|
>
|
|
451
450
|
<div>content</div>
|
|
452
451
|
</WorkspaceShell>,
|
|
@@ -462,13 +461,15 @@ describe("WorkspaceShell", () => {
|
|
|
462
461
|
expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
|
|
463
462
|
});
|
|
464
463
|
|
|
465
|
-
//
|
|
466
|
-
|
|
464
|
+
// fill={false} is the escape hatch back to page-scroll: must not apply
|
|
465
|
+
// viewport-lock classes.
|
|
466
|
+
test("fill={false} does not apply h-svh or min-h-0", () => {
|
|
467
467
|
renderShell(
|
|
468
468
|
<WorkspaceShell
|
|
469
469
|
brand={<div>Brand</div>}
|
|
470
470
|
schema={schema}
|
|
471
471
|
user={{ id: "u1", roles: ["admin"] }}
|
|
472
|
+
fill={false}
|
|
472
473
|
>
|
|
473
474
|
<div>content</div>
|
|
474
475
|
</WorkspaceShell>,
|
package/src/icons.tsx
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
// Icon registry: symbolic `NavIconKey` → lucide-react component. `NavIconKey`
|
|
2
|
+
// is the closed vocabulary a feature author can write (packages/types/src/
|
|
3
|
+
// nav-icon.ts); `satisfies` below makes this map a compile-time drift
|
|
4
|
+
// guard — a key added to one without the other fails the build.
|
|
5
|
+
//
|
|
6
|
+
// Runtime data (provider-supplied nav/action icons) is only typed as plain
|
|
7
|
+
// `string` at the resolved-tree layer, so an unknown key can still show up
|
|
8
|
+
// at runtime on occasion — `Icon` falls back to rendering the raw key as
|
|
9
|
+
// text rather than crashing, mirroring the old `ActionGlyph` behavior in
|
|
10
|
+
// nav-tree.tsx.
|
|
11
|
+
//
|
|
12
|
+
// Buttons resolve icons through the same map — importing it from the nav
|
|
13
|
+
// layout module would couple them to the sidebar.
|
|
1
14
|
import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
15
|
import {
|
|
16
|
+
AlertTriangle,
|
|
17
|
+
Archive,
|
|
3
18
|
ArrowLeft,
|
|
4
19
|
ArrowRight,
|
|
5
20
|
BarChart3,
|
|
@@ -9,17 +24,27 @@ import {
|
|
|
9
24
|
Calculator,
|
|
10
25
|
CalendarDays,
|
|
11
26
|
Check,
|
|
27
|
+
CheckCircle2,
|
|
28
|
+
ChevronDown,
|
|
29
|
+
ChevronRight,
|
|
12
30
|
ClipboardList,
|
|
31
|
+
Clock,
|
|
13
32
|
Coins,
|
|
14
33
|
Copy,
|
|
15
34
|
CreditCard,
|
|
16
35
|
Download,
|
|
36
|
+
ExternalLink,
|
|
37
|
+
Eye,
|
|
38
|
+
EyeOff,
|
|
17
39
|
FileText,
|
|
40
|
+
Filter,
|
|
41
|
+
Flag,
|
|
18
42
|
Folder,
|
|
19
43
|
FolderOpen,
|
|
20
44
|
Gauge,
|
|
21
45
|
Hash,
|
|
22
46
|
Home,
|
|
47
|
+
Info,
|
|
23
48
|
KeyRound,
|
|
24
49
|
Languages,
|
|
25
50
|
Layers,
|
|
@@ -28,14 +53,23 @@ import {
|
|
|
28
53
|
LineChart,
|
|
29
54
|
Link,
|
|
30
55
|
List,
|
|
56
|
+
Loader2,
|
|
31
57
|
Lock,
|
|
32
58
|
Mail,
|
|
59
|
+
MapPin,
|
|
60
|
+
MoreHorizontal,
|
|
61
|
+
MoreVertical,
|
|
33
62
|
Package,
|
|
34
63
|
Palette,
|
|
64
|
+
Pencil,
|
|
65
|
+
Phone,
|
|
35
66
|
PiggyBank,
|
|
36
67
|
Plus,
|
|
68
|
+
Printer,
|
|
37
69
|
Receipt,
|
|
70
|
+
RefreshCw,
|
|
38
71
|
Rocket,
|
|
72
|
+
Save,
|
|
39
73
|
Search,
|
|
40
74
|
Send,
|
|
41
75
|
Server,
|
|
@@ -44,19 +78,23 @@ import {
|
|
|
44
78
|
Shield,
|
|
45
79
|
ShieldCheck,
|
|
46
80
|
Sparkles,
|
|
81
|
+
Star,
|
|
47
82
|
Table,
|
|
48
83
|
Tag,
|
|
49
84
|
Trash2,
|
|
50
85
|
TrendingUp,
|
|
86
|
+
Undo2,
|
|
51
87
|
Upload,
|
|
52
88
|
User,
|
|
53
89
|
Users,
|
|
54
90
|
Wallet,
|
|
55
91
|
Wand2,
|
|
56
92
|
X,
|
|
93
|
+
XCircle,
|
|
57
94
|
} from "lucide-react";
|
|
95
|
+
import type { ReactNode } from "react";
|
|
96
|
+
import { cn } from "./lib/cn";
|
|
58
97
|
|
|
59
|
-
// Buttons resolve icons through the same map, importing it from the nav layout module would couple them to the sidebar.
|
|
60
98
|
export const NAV_ICONS = {
|
|
61
99
|
dashboard: LayoutDashboard,
|
|
62
100
|
"layout-grid": LayoutGrid,
|
|
@@ -113,4 +151,49 @@ export const NAV_ICONS = {
|
|
|
113
151
|
"arrow-left": ArrowLeft,
|
|
114
152
|
"arrow-right": ArrowRight,
|
|
115
153
|
copy: Copy,
|
|
154
|
+
pencil: Pencil,
|
|
155
|
+
eye: Eye,
|
|
156
|
+
"eye-off": EyeOff,
|
|
157
|
+
filter: Filter,
|
|
158
|
+
refresh: RefreshCw,
|
|
159
|
+
"more-horizontal": MoreHorizontal,
|
|
160
|
+
"more-vertical": MoreVertical,
|
|
161
|
+
"external-link": ExternalLink,
|
|
162
|
+
"chevron-down": ChevronDown,
|
|
163
|
+
"chevron-right": ChevronRight,
|
|
164
|
+
save: Save,
|
|
165
|
+
undo: Undo2,
|
|
166
|
+
archive: Archive,
|
|
167
|
+
star: Star,
|
|
168
|
+
flag: Flag,
|
|
169
|
+
clock: Clock,
|
|
170
|
+
"map-pin": MapPin,
|
|
171
|
+
phone: Phone,
|
|
172
|
+
printer: Printer,
|
|
173
|
+
"alert-triangle": AlertTriangle,
|
|
174
|
+
info: Info,
|
|
175
|
+
"check-circle": CheckCircle2,
|
|
176
|
+
"x-circle": XCircle,
|
|
177
|
+
loader: Loader2,
|
|
116
178
|
} as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
|
|
179
|
+
|
|
180
|
+
// Widened alias for runtime lookups against the plain `string` icon keys
|
|
181
|
+
// resolved-tree nodes carry — not the closed NavIconKey union NAV_ICONS
|
|
182
|
+
// itself is typed against.
|
|
183
|
+
const ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
|
|
184
|
+
|
|
185
|
+
export function Icon({
|
|
186
|
+
name,
|
|
187
|
+
className,
|
|
188
|
+
}: {
|
|
189
|
+
readonly name: NavIconKey;
|
|
190
|
+
readonly className?: string;
|
|
191
|
+
}): ReactNode {
|
|
192
|
+
const LucideIcon = Object.hasOwn(NAV_ICONS, name) ? ICON_LOOKUP[name] : undefined;
|
|
193
|
+
if (LucideIcon !== undefined) return <LucideIcon aria-hidden="true" className={className} />;
|
|
194
|
+
return (
|
|
195
|
+
<span aria-hidden="true" className={cn("text-xs", className)}>
|
|
196
|
+
{name}
|
|
197
|
+
</span>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -13,12 +13,12 @@ export type AppLayoutProps = {
|
|
|
13
13
|
readonly sidebar?: ReactNode;
|
|
14
14
|
readonly children: ReactNode;
|
|
15
15
|
readonly testId?: string;
|
|
16
|
-
/** Viewport-fit Shell. true → Wurzel = `h-screen` (fixe
|
|
17
|
-
* Sidebar/Topbar bleiben stehen, der Main-Bereich
|
|
18
|
-
* (`min-h-0` + `overflow-auto`). false
|
|
19
|
-
* `min-h-screen`-Flow, die ganze Seite scrollt
|
|
20
|
-
*
|
|
21
|
-
* Clippt nie — der Content scrollt in `main` statt im Body. */
|
|
16
|
+
/** Viewport-fit Shell. Default `true` → Wurzel = `h-screen` (fixe
|
|
17
|
+
* Viewport-Höhe), Sidebar/Topbar bleiben stehen, der Main-Bereich
|
|
18
|
+
* scrollt INNEN (`min-h-0` + `overflow-auto`). `false` → klassischer
|
|
19
|
+
* `min-h-screen`-Flow, die ganze Seite scrollt — Escape-Hatch für
|
|
20
|
+
* eine öffentliche, lange Content-Seite die echten Body-Scroll
|
|
21
|
+
* braucht. Clippt nie — der Content scrollt in `main` statt im Body. */
|
|
22
22
|
readonly fill?: boolean;
|
|
23
23
|
/** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
|
|
24
24
|
* Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
|
|
@@ -32,7 +32,7 @@ export function AppLayout({
|
|
|
32
32
|
sidebar,
|
|
33
33
|
children,
|
|
34
34
|
testId,
|
|
35
|
-
fill,
|
|
35
|
+
fill = true,
|
|
36
36
|
className,
|
|
37
37
|
mainClassName,
|
|
38
38
|
}: AppLayoutProps): ReactNode {
|
|
@@ -68,7 +68,8 @@ export type DefaultAppShellProps = {
|
|
|
68
68
|
* Plan-Banner. */
|
|
69
69
|
readonly sidebarFooter?: ReactNode;
|
|
70
70
|
/** Viewport-fit Shell. true → fixe Viewport-Höhe (`h-svh`), der Content
|
|
71
|
-
* scrollt INNEN statt der ganzen Seite. Default
|
|
71
|
+
* scrollt INNEN statt der ganzen Seite. Default true — `fill={false}`
|
|
72
|
+
* schaltet zurück auf Seiten-Scroll. */
|
|
72
73
|
readonly fill?: boolean;
|
|
73
74
|
/** Screen-Content der im SidebarInset gerendert wird. */
|
|
74
75
|
readonly children: ReactNode;
|
|
@@ -82,7 +83,7 @@ export function DefaultAppShell({
|
|
|
82
83
|
headerActions,
|
|
83
84
|
navBadges,
|
|
84
85
|
sidebarFooter,
|
|
85
|
-
fill,
|
|
86
|
+
fill = true,
|
|
86
87
|
children,
|
|
87
88
|
}: DefaultAppShellProps): ReactNode {
|
|
88
89
|
const fillCls = fillClasses(fill);
|
|
@@ -80,7 +80,8 @@ export type WorkspaceShellProps = {
|
|
|
80
80
|
/** Runtime badge slot per nav leaf (bare nav id), same as DefaultAppShell. */
|
|
81
81
|
readonly navBadges?: ReadonlyMap<string, ReactNode>;
|
|
82
82
|
/** Viewport-fit shell. true → fixed viewport height (`h-svh`), content
|
|
83
|
-
* scrolls INSIDE instead of the whole page. Default
|
|
83
|
+
* scrolls INSIDE instead of the whole page. Default true — set
|
|
84
|
+
* `fill={false}` to opt back into page scroll. */
|
|
84
85
|
readonly fill?: boolean;
|
|
85
86
|
/** Screen content. */
|
|
86
87
|
readonly children: ReactNode;
|
|
@@ -94,7 +95,7 @@ export function WorkspaceShell({
|
|
|
94
95
|
initialWorkspaceId,
|
|
95
96
|
sidebarFooter,
|
|
96
97
|
navBadges,
|
|
97
|
-
fill,
|
|
98
|
+
fill = true,
|
|
98
99
|
children,
|
|
99
100
|
}: WorkspaceShellProps): ReactNode {
|
|
100
101
|
const app = useMemo(() => toAppSchema(schema), [schema]);
|
|
@@ -25,3 +25,40 @@ describe("DefaultButton className/ref (fw#1831)", () => {
|
|
|
25
25
|
expect(ref.current).toBe(screen.getByTestId("btn"));
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
describe("DefaultButton icon (fw-ui-defaults)", () => {
|
|
30
|
+
test("icon prop renders the resolved icon left of the text", () => {
|
|
31
|
+
render(
|
|
32
|
+
<Button icon="trash" testId="btn">
|
|
33
|
+
Delete
|
|
34
|
+
</Button>,
|
|
35
|
+
);
|
|
36
|
+
const btn = screen.getByTestId("btn");
|
|
37
|
+
expect(btn.querySelector("svg")).not.toBeNull();
|
|
38
|
+
expect(btn.textContent).toBe("Delete");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("size='icon' + a resolved icon still renders icon, children and aria-label", () => {
|
|
42
|
+
render(
|
|
43
|
+
<Button icon="trash" size="icon" ariaLabel="Delete" testId="btn">
|
|
44
|
+
Delete
|
|
45
|
+
</Button>,
|
|
46
|
+
);
|
|
47
|
+
const btn = screen.getByTestId("btn");
|
|
48
|
+
expect(btn.textContent).toBe("Delete");
|
|
49
|
+
expect(btn.querySelector("svg")).not.toBeNull();
|
|
50
|
+
expect(btn.getAttribute("aria-label")).toBe("Delete");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("unknown icon key: no crash, falls back to rendering children only", () => {
|
|
54
|
+
render(
|
|
55
|
+
// @ts-expect-error — exercising the runtime fallback for a schema-supplied key outside the closed IconKey union
|
|
56
|
+
<Button icon="not-a-real-icon" testId="btn">
|
|
57
|
+
Delete
|
|
58
|
+
</Button>,
|
|
59
|
+
);
|
|
60
|
+
const btn = screen.getByTestId("btn");
|
|
61
|
+
expect(btn.querySelector("svg")).toBeNull();
|
|
62
|
+
expect(btn.textContent).toBe("Delete");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Segment control for `kind: "select"` with a small closed option set
|
|
2
|
+
// (edit-existing screenshot feedback): a Status field with 3 short values
|
|
3
|
+
// no longer stretches into a full-width dropdown. Threshold: ≤4 options,
|
|
4
|
+
// each label ≤14 chars — otherwise the unchanged ComboboxInput dropdown.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { fireEvent } from "@testing-library/react";
|
|
8
|
+
import { render, screen } from "../../__tests__/test-utils";
|
|
9
|
+
import { defaultPrimitives } from "../index";
|
|
10
|
+
|
|
11
|
+
const { Field, Input } = defaultPrimitives;
|
|
12
|
+
|
|
13
|
+
function renderSelect(
|
|
14
|
+
options: readonly string[],
|
|
15
|
+
overrides: { readonly value?: string; readonly disabled?: boolean } = {},
|
|
16
|
+
): string[] {
|
|
17
|
+
const changes: string[] = [];
|
|
18
|
+
render(
|
|
19
|
+
<Field id="status" label="Status" testId="field-status">
|
|
20
|
+
<Input
|
|
21
|
+
kind="select"
|
|
22
|
+
id="status"
|
|
23
|
+
name="status"
|
|
24
|
+
value={overrides.value ?? options[0] ?? ""}
|
|
25
|
+
onChange={(v) => changes.push(v)}
|
|
26
|
+
options={options}
|
|
27
|
+
{...(overrides.disabled !== undefined && { disabled: overrides.disabled })}
|
|
28
|
+
/>
|
|
29
|
+
</Field>,
|
|
30
|
+
);
|
|
31
|
+
return changes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("DefaultInput select → segmented control (edit-existing feedback)", () => {
|
|
35
|
+
test("≤4 short options render the segmented control, not the dropdown", () => {
|
|
36
|
+
renderSelect(["Draft", "Review", "Published"]);
|
|
37
|
+
expect(screen.queryByTestId("combobox-status")).toBeNull();
|
|
38
|
+
expect(screen.getByRole("radiogroup")).toBeTruthy();
|
|
39
|
+
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("4 options render the segmented control", () => {
|
|
43
|
+
renderSelect(["Draft", "Review", "Published", "Archived"]);
|
|
44
|
+
expect(screen.queryByTestId("combobox-status")).toBeNull();
|
|
45
|
+
expect(screen.getByRole("radiogroup")).toBeTruthy();
|
|
46
|
+
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("5 options keep the dropdown", () => {
|
|
50
|
+
renderSelect(["Draft", "Review", "Published", "Archived", "Deleted"]);
|
|
51
|
+
expect(screen.queryByRole("radiogroup")).toBeNull();
|
|
52
|
+
expect(screen.getByTestId("combobox-status")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("3 options with one long label (>14 chars) keep the dropdown", () => {
|
|
56
|
+
renderSelect(["Draft", "Review", "Published Status"]);
|
|
57
|
+
expect(screen.queryByRole("radiogroup")).toBeNull();
|
|
58
|
+
expect(screen.getByTestId("combobox-status")).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("clicking a segment reports the same value the dropdown's onChange would give", () => {
|
|
62
|
+
const changes = renderSelect(["Draft", "Review", "Published"], { value: "Draft" });
|
|
63
|
+
fireEvent.click(screen.getByRole("radio", { name: "Review" }));
|
|
64
|
+
expect(changes).toEqual(["Review"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("group has an accessible name and the selected segment is aria-checked", () => {
|
|
68
|
+
renderSelect(["Draft", "Review", "Published"], { value: "Review" });
|
|
69
|
+
expect(screen.getByRole("radiogroup", { name: "Status" })).toBeTruthy();
|
|
70
|
+
expect(screen.getByRole("radio", { name: "Review" }).getAttribute("aria-checked")).toBe("true");
|
|
71
|
+
expect(screen.getByRole("radio", { name: "Draft" }).getAttribute("aria-checked")).toBe("false");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("disabled prevents selection", () => {
|
|
75
|
+
const changes = renderSelect(["Draft", "Review", "Published"], {
|
|
76
|
+
value: "Draft",
|
|
77
|
+
disabled: true,
|
|
78
|
+
});
|
|
79
|
+
const review = screen.getByRole("radio", { name: "Review" });
|
|
80
|
+
expect((review as HTMLButtonElement).disabled).toBe(true);
|
|
81
|
+
fireEvent.click(review);
|
|
82
|
+
expect(changes).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|