@cosmicdrift/kumiko-renderer-web 0.133.0 → 0.134.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.
@@ -1,40 +1,72 @@
1
1
  // Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
2
- // Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable) in
3
- // einem responsiven Grid. Registriert via DashboardBodyProvider in
4
- // createKumikoApp — der KumikoScreen-Switch bleibt plattform-agnostisch.
2
+ // Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable, FeedList,
3
+ // ProgressList) in einem responsiven Grid. Registriert via
4
+ // DashboardBodyProvider in createKumikoApp — der KumikoScreen-Switch bleibt
5
+ // plattform-agnostisch.
5
6
  //
6
7
  // Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
7
- // stat → flaches Record, valueField/subField/toneField zeigen auf
8
- // anzeigefertige Werte (der Query-Handler formatiert).
9
- // chart { points: { atMs, value | null }[], windowStartMs, windowEndMs }
10
- // list → paged envelope { rows, nextCursor, total? } wie projectionList.
8
+ // stat → flaches Record, valueField/subField/toneField zeigen auf
9
+ // anzeigefertige Werte (der Query-Handler formatiert).
10
+ // stat-group mehrere stat-Panels unter einem Sektions-Titel, jedes
11
+ // Kind bleibt eine eigenständige Query.
12
+ // chart → { points: { atMs, value | null }[], windowStartMs,
13
+ // windowEndMs }
14
+ // list → paged envelope { rows, nextCursor, total? } wie
15
+ // projectionList.
16
+ // feed → { rows: { id, primary, trailing? }[] }
17
+ // progress-list → { rows: { id, label, value, fraction }[] }
18
+ // custom → keine Query — eine über extensionSectionComponents
19
+ // registrierte App-Komponente holt sich ihre Daten selbst.
20
+ //
21
+ // Screen-Filter (DashboardFilterDefinition): der gewählte Wert wird unter
22
+ // `filter.id` in JEDE Panel-Query gemerged. useQuery refetcht automatisch
23
+ // über seinen bestehenden payloadKey-Mechanismus — kein Sonderfall nötig.
11
24
 
12
25
  import type {
13
26
  DashboardChartPanel,
27
+ DashboardCustomPanel,
28
+ DashboardFeedPanel,
14
29
  DashboardListPanel,
30
+ DashboardPanelDefinition,
31
+ DashboardProgressListPanel,
32
+ DashboardScreenDefinition,
33
+ DashboardStatGroupPanel,
15
34
  DashboardStatPanel,
16
35
  } from "@cosmicdrift/kumiko-framework/ui-types";
17
36
  import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
18
- import { type DashboardBodyProps, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
- import type { ReactNode } from "react";
37
+ import {
38
+ type DashboardBodyProps,
39
+ extensionSectionName,
40
+ useExtensionSectionComponent,
41
+ usePrimitives,
42
+ useQuery,
43
+ useTranslation,
44
+ } from "@cosmicdrift/kumiko-renderer";
45
+ import { type ReactNode, useEffect, useState } from "react";
20
46
  import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
47
+ import { FeedList, type FeedRow } from "../widgets/feed-list";
48
+ import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
21
49
  import { QueryTable } from "../widgets/query-table";
22
50
  import { SectionCard } from "../widgets/section-card";
23
51
  import { StatCard, type StatTone } from "../widgets/stat";
24
52
  import { ErrorState, LoadingState } from "../widgets/states";
25
53
 
26
54
  const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
55
+ const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
56
+ const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
27
57
 
28
58
  function StatPanelBody({
29
59
  panel,
30
60
  label,
61
+ filterParams,
31
62
  }: {
32
63
  readonly panel: DashboardStatPanel;
33
64
  readonly label: string;
65
+ readonly filterParams: Readonly<Record<string, unknown>>;
34
66
  }): ReactNode {
35
67
  const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
36
68
  panel.query,
37
- {},
69
+ filterParams,
38
70
  { live: true },
39
71
  );
40
72
  if (loading && data === null) return <LoadingState rows={2} />;
@@ -55,6 +87,32 @@ function StatPanelBody({
55
87
  );
56
88
  }
57
89
 
90
+ function StatGroupPanelBody({
91
+ panel,
92
+ label,
93
+ filterParams,
94
+ }: {
95
+ readonly panel: DashboardStatGroupPanel;
96
+ readonly label: string;
97
+ readonly filterParams: Readonly<Record<string, unknown>>;
98
+ }): ReactNode {
99
+ const t = useTranslation();
100
+ return (
101
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
102
+ <section className="grid grid-cols-1 gap-3 sm:grid-cols-3">
103
+ {panel.stats.map((stat) => (
104
+ <StatPanelBody
105
+ key={stat.id}
106
+ panel={stat}
107
+ label={t(stat.label)}
108
+ filterParams={filterParams}
109
+ />
110
+ ))}
111
+ </section>
112
+ </SectionCard>
113
+ );
114
+ }
115
+
58
116
  type TimeseriesEnvelope = {
59
117
  readonly points: readonly TimeseriesPoint[];
60
118
  readonly windowStartMs: number;
@@ -64,14 +122,16 @@ type TimeseriesEnvelope = {
64
122
  function ChartPanelBody({
65
123
  panel,
66
124
  label,
125
+ filterParams,
67
126
  }: {
68
127
  readonly panel: DashboardChartPanel;
69
128
  readonly label: string;
129
+ readonly filterParams: Readonly<Record<string, unknown>>;
70
130
  }): ReactNode {
71
131
  const t = useTranslation();
72
132
  const { data, error, loading, refetch } = useQuery<TimeseriesEnvelope>(
73
133
  panel.query,
74
- {},
134
+ filterParams,
75
135
  { live: true },
76
136
  );
77
137
  if (loading && data === null) return <LoadingState rows={3} />;
@@ -92,15 +152,18 @@ function ChartPanelBody({
92
152
  function ListPanelBody({
93
153
  panel,
94
154
  label,
155
+ filterParams,
95
156
  }: {
96
157
  readonly panel: DashboardListPanel;
97
158
  readonly label: string;
159
+ readonly filterParams: Readonly<Record<string, unknown>>;
98
160
  }): ReactNode {
99
161
  const t = useTranslation();
100
162
  return (
101
163
  <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
102
164
  <QueryTable<{ readonly rows: readonly Readonly<Record<string, unknown>>[] }>
103
165
  query={panel.query}
166
+ payload={filterParams}
104
167
  live
105
168
  columns={panel.columns.map((c) => {
106
169
  const normalized = normalizeListColumn(c);
@@ -115,32 +178,212 @@ function ListPanelBody({
115
178
  );
116
179
  }
117
180
 
181
+ type FeedEnvelope = {
182
+ readonly rows: readonly { readonly primary: string; readonly trailing?: string }[];
183
+ };
184
+
185
+ function FeedPanelBody({
186
+ panel,
187
+ label,
188
+ filterParams,
189
+ }: {
190
+ readonly panel: DashboardFeedPanel;
191
+ readonly label: string;
192
+ readonly filterParams: Readonly<Record<string, unknown>>;
193
+ }): ReactNode {
194
+ const t = useTranslation();
195
+ const { data, error, loading, refetch } = useQuery<FeedEnvelope>(panel.query, filterParams, {
196
+ live: true,
197
+ });
198
+ if (loading && data === null) return <LoadingState rows={3} />;
199
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
200
+ const rows: readonly FeedRow[] = (data?.rows ?? []).map((row, i) => ({ id: String(i), ...row }));
201
+ return (
202
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
203
+ <FeedList rows={rows} emptyContent={t(panel.emptyLabel ?? "kumiko.list.no-entries")} />
204
+ </SectionCard>
205
+ );
206
+ }
207
+
208
+ type ProgressListEnvelope = {
209
+ readonly rows: readonly {
210
+ readonly label: string;
211
+ readonly value: string;
212
+ readonly fraction: number;
213
+ }[];
214
+ };
215
+
216
+ function ProgressListPanelBody({
217
+ panel,
218
+ label,
219
+ filterParams,
220
+ }: {
221
+ readonly panel: DashboardProgressListPanel;
222
+ readonly label: string;
223
+ readonly filterParams: Readonly<Record<string, unknown>>;
224
+ }): ReactNode {
225
+ const t = useTranslation();
226
+ const { data, error, loading, refetch } = useQuery<ProgressListEnvelope>(
227
+ panel.query,
228
+ filterParams,
229
+ { live: true },
230
+ );
231
+ if (loading && data === null) return <LoadingState rows={3} />;
232
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
233
+ const rows: readonly ProgressListRow[] = (data?.rows ?? []).map((row, i) => ({
234
+ id: String(i),
235
+ ...row,
236
+ }));
237
+ return (
238
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
239
+ <ProgressList rows={rows} emptyContent={t("kumiko.list.no-entries")} />
240
+ </SectionCard>
241
+ );
242
+ }
243
+
244
+ // Löst panel.component über dieselbe extensionSectionComponents-Registry auf
245
+ // wie entityEdit-Extension-Sections und List-Header-Slots — bleibt an seiner
246
+ // Array-Position statt in einen separaten Slot zu wandern (siehe Datei-Kopf-
247
+ // Kommentar zur Registry). Rendert nichts + dev-Warnung bei unregistriertem
248
+ // Namen, analog zu ListHeaderSlotMount in render-list.tsx.
249
+ function CustomPanelBody({
250
+ panel,
251
+ screenId,
252
+ filterParams,
253
+ }: {
254
+ readonly panel: DashboardCustomPanel;
255
+ readonly screenId: string;
256
+ readonly filterParams: Readonly<Record<string, unknown>>;
257
+ }): ReactNode {
258
+ const name = extensionSectionName(panel.component);
259
+ const Component = useExtensionSectionComponent(name);
260
+ useEffect(() => {
261
+ if (name !== undefined && Component === undefined) {
262
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
263
+ console.warn(
264
+ `[kumiko] Dashboard custom-panel "${panel.id}" on screen "${screenId}" references component ` +
265
+ `"${name}", which is not registered in clientFeatures.extensionSectionComponents — the panel renders nothing.`,
266
+ );
267
+ }
268
+ }, [name, Component, panel.id, screenId]);
269
+ if (Component === undefined) return null;
270
+ // Dashboard-Panels haben keine Entity — entityName trägt die screen.id,
271
+ // damit ExtensionSectionProps nicht extra für diesen einen Mount-Ort
272
+ // aufgeweicht werden muss (das bräche jede bestehende registrierte
273
+ // Section, die entityName als garantiert gesetzten string erwartet).
274
+ return (
275
+ <Component
276
+ entityName={screenId}
277
+ entityId={null}
278
+ screenId={screenId}
279
+ filterParams={filterParams}
280
+ />
281
+ );
282
+ }
283
+
284
+ // Screen-Filter: hält den gewählten Wert, rendert die Combobox, und liefert
285
+ // den Payload-Merge für jede Panel-Query. Statische `options` werden als
286
+ // i18n-Keys übersetzt; `optionsQuery`-Ergebnisse sind Server-Daten und werden
287
+ // unverändert übernommen.
288
+ function useFilterParams(screen: DashboardScreenDefinition): {
289
+ readonly params: Readonly<Record<string, unknown>>;
290
+ readonly picker: ReactNode;
291
+ } {
292
+ const { Field, Input } = usePrimitives();
293
+ const t = useTranslation();
294
+ const filter = screen.filter;
295
+ const [value, setValue] = useState("");
296
+ const optionsQueryResult = useQuery<{
297
+ readonly rows: readonly { readonly value: string; readonly label: string }[];
298
+ }>(filter?.optionsQuery ?? "", {}, { enabled: filter?.optionsQuery !== undefined });
299
+
300
+ if (filter === undefined) {
301
+ return { params: {}, picker: null };
302
+ }
303
+
304
+ const dynamicOptions =
305
+ filter.optionsQuery !== undefined
306
+ ? (optionsQueryResult.data?.rows ?? [])
307
+ : (filter.options ?? []).map((o) => ({ value: o.value, label: t(o.label) }));
308
+ const allLabel = t(filter.allLabel ?? "kumiko.dashboard.filter.all");
309
+ const options = [{ value: "", label: allLabel }, ...dynamicOptions];
310
+
311
+ const picker = (
312
+ <div className="max-w-xs">
313
+ <Field id={`dashboard-filter-${filter.id}`} label={t(filter.label)}>
314
+ <Input
315
+ kind="combobox"
316
+ id={`dashboard-filter-${filter.id}`}
317
+ name={filter.id}
318
+ options={options}
319
+ value={value}
320
+ onChange={setValue}
321
+ placeholder={filter.placeholder !== undefined ? t(filter.placeholder) : allLabel}
322
+ />
323
+ </Field>
324
+ </div>
325
+ );
326
+ const params = value === "" ? {} : { [filter.id]: value };
327
+ return { params, picker };
328
+ }
329
+
330
+ function panelSpanClassName(panel: DashboardPanelDefinition): string | undefined {
331
+ if (panel.kind === "stat") return undefined;
332
+ if (panel.kind === "feed" || panel.kind === "progress-list") return HALF_PANEL;
333
+ return WIDE_PANEL;
334
+ }
335
+
336
+ function PanelBody({
337
+ panel,
338
+ label,
339
+ screenId,
340
+ filterParams,
341
+ }: {
342
+ readonly panel: DashboardPanelDefinition;
343
+ readonly label: string;
344
+ readonly screenId: string;
345
+ readonly filterParams: Readonly<Record<string, unknown>>;
346
+ }): ReactNode {
347
+ if (panel.kind === "stat")
348
+ return <StatPanelBody panel={panel} label={label} filterParams={filterParams} />;
349
+ if (panel.kind === "stat-group") {
350
+ return <StatGroupPanelBody panel={panel} label={label} filterParams={filterParams} />;
351
+ }
352
+ if (panel.kind === "chart")
353
+ return <ChartPanelBody panel={panel} label={label} filterParams={filterParams} />;
354
+ if (panel.kind === "list")
355
+ return <ListPanelBody panel={panel} label={label} filterParams={filterParams} />;
356
+ if (panel.kind === "feed")
357
+ return <FeedPanelBody panel={panel} label={label} filterParams={filterParams} />;
358
+ if (panel.kind === "progress-list") {
359
+ return <ProgressListPanelBody panel={panel} label={label} filterParams={filterParams} />;
360
+ }
361
+ return <CustomPanelBody panel={panel} screenId={screenId} filterParams={filterParams} />;
362
+ }
363
+
118
364
  export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
119
365
  const t = useTranslation();
120
366
  const effectiveTranslate = translate ?? t;
367
+ const { params: filterParams, picker } = useFilterParams(screen);
121
368
  return (
122
- <div
123
- className="grid gap-4 p-6 sm:grid-cols-2 lg:grid-cols-4"
124
- data-testid={`dashboard-${screen.id}`}
125
- >
126
- {screen.panels.map((panel) => {
127
- const label = effectiveTranslate(panel.label);
128
- if (panel.kind === "stat") {
129
- return <StatPanelBody key={panel.id} panel={panel} label={label} />;
130
- }
131
- if (panel.kind === "chart") {
369
+ <div className="flex flex-col gap-4 p-6" data-testid={`dashboard-${screen.id}`}>
370
+ {picker}
371
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
372
+ {screen.panels.map((panel) => {
373
+ const label = panel.kind === "custom" ? "" : effectiveTranslate(panel.label);
374
+ const span = panelSpanClassName(panel);
132
375
  return (
133
- <div key={panel.id} className="sm:col-span-2 lg:col-span-4">
134
- <ChartPanelBody panel={panel} label={label} />
376
+ <div key={panel.id} className={span}>
377
+ <PanelBody
378
+ panel={panel}
379
+ label={label}
380
+ screenId={screen.id}
381
+ filterParams={filterParams}
382
+ />
135
383
  </div>
136
384
  );
137
- }
138
- return (
139
- <div key={panel.id} className="sm:col-span-2 lg:col-span-4">
140
- <ListPanelBody panel={panel} label={label} />
141
- </div>
142
- );
143
- })}
385
+ })}
386
+ </div>
144
387
  </div>
145
388
  );
146
389
  }
package/src/index.ts CHANGED
@@ -155,6 +155,8 @@ export {
155
155
  } from "./tokens";
156
156
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
157
157
  export type {
158
+ FeedRow,
159
+ ProgressListRow,
158
160
  QueryTableColumn,
159
161
  QueryTableProps,
160
162
  StatDelta,
@@ -168,10 +170,12 @@ export {
168
170
  DetailList,
169
171
  EmptyState,
170
172
  ErrorState,
173
+ FeedList,
171
174
  LoadingState,
172
175
  MiniStat,
173
176
  ModeSwitch,
174
177
  ProgressBar,
178
+ ProgressList,
175
179
  QueryTable,
176
180
  SectionCard,
177
181
  Sparkline,
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type FeedRow = {
4
+ readonly id: string;
5
+ readonly primary: string;
6
+ readonly trailing?: string;
7
+ };
8
+
9
+ /** Nicht-tabellarische Kurzliste (z.B. "nächste Termine") — Primary-Text +
10
+ * optionaler rechtsbündiger Trailing-Wert pro Zeile. */
11
+ export function FeedList({
12
+ rows,
13
+ emptyContent,
14
+ testId,
15
+ }: {
16
+ readonly rows: readonly FeedRow[];
17
+ readonly emptyContent?: ReactNode;
18
+ readonly testId?: string;
19
+ }): ReactNode {
20
+ if (rows.length === 0) {
21
+ return <div data-testid={testId}>{emptyContent}</div>;
22
+ }
23
+ return (
24
+ <ul data-testid={testId} className="flex max-h-64 flex-col overflow-y-auto pr-1 text-sm">
25
+ {rows.map((row) => (
26
+ <li key={row.id} className="flex justify-between gap-4 border-b py-1.5 last:border-b-0">
27
+ <span>{row.primary}</span>
28
+ {row.trailing !== undefined && (
29
+ <span className="tabular-nums text-muted-foreground">{row.trailing}</span>
30
+ )}
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ );
35
+ }
@@ -12,8 +12,10 @@ export {
12
12
  } from "./charts";
13
13
  export { CollapsibleSection } from "./collapsible-section";
14
14
  export { DetailList } from "./detail-list";
15
+ export { FeedList, type FeedRow } from "./feed-list";
15
16
  export { ModeSwitch } from "./mode-switch";
16
17
  export { ProgressBar } from "./progress-bar";
18
+ export { ProgressList, type ProgressListRow } from "./progress-list";
17
19
  export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
18
20
  export { SectionCard } from "./section-card";
19
21
  export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import { ProgressBar } from "./progress-bar";
3
+
4
+ export type ProgressListRow = {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ readonly value: string;
8
+ readonly fraction: number;
9
+ };
10
+
11
+ /** Liste aus Label/Wert-Kopfzeile + Fortschrittsbalken pro Eintrag (z.B.
12
+ * Tilgungsfortschritt pro Kredit). */
13
+ export function ProgressList({
14
+ rows,
15
+ emptyContent,
16
+ testId,
17
+ }: {
18
+ readonly rows: readonly ProgressListRow[];
19
+ readonly emptyContent?: ReactNode;
20
+ readonly testId?: string;
21
+ }): ReactNode {
22
+ if (rows.length === 0) {
23
+ return <div data-testid={testId}>{emptyContent}</div>;
24
+ }
25
+ return (
26
+ <ul data-testid={testId} className="flex max-h-64 flex-col gap-3 overflow-y-auto pr-1">
27
+ {rows.map((row) => (
28
+ <li key={row.id} className="flex flex-col gap-1">
29
+ <div className="flex items-baseline justify-between gap-2 text-sm">
30
+ <span className="font-medium">{row.label}</span>
31
+ <span className="tabular-nums text-muted-foreground">{row.value}</span>
32
+ </div>
33
+ <ProgressBar value={row.fraction} />
34
+ </li>
35
+ ))}
36
+ </ul>
37
+ );
38
+ }
package/src/ui/card.tsx DELETED
@@ -1,93 +0,0 @@
1
- // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
- import * as React from "react"
3
-
4
- import { cn } from "../lib/cn"
5
-
6
- function Card({ className, ...props }: React.ComponentProps<"div">) {
7
- return (
8
- <div
9
- data-slot="card"
10
- className={cn(
11
- "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
12
- className
13
- )}
14
- {...props}
15
- />
16
- )
17
- }
18
-
19
- function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
20
- return (
21
- <div
22
- data-slot="card-header"
23
- className={cn(
24
- "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
25
- className
26
- )}
27
- {...props}
28
- />
29
- )
30
- }
31
-
32
- function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
33
- return (
34
- <div
35
- data-slot="card-title"
36
- className={cn("leading-none font-semibold", className)}
37
- {...props}
38
- />
39
- )
40
- }
41
-
42
- function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
43
- return (
44
- <div
45
- data-slot="card-description"
46
- className={cn("text-sm text-muted-foreground", className)}
47
- {...props}
48
- />
49
- )
50
- }
51
-
52
- function CardAction({ className, ...props }: React.ComponentProps<"div">) {
53
- return (
54
- <div
55
- data-slot="card-action"
56
- className={cn(
57
- "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
58
- className
59
- )}
60
- {...props}
61
- />
62
- )
63
- }
64
-
65
- function CardContent({ className, ...props }: React.ComponentProps<"div">) {
66
- return (
67
- <div
68
- data-slot="card-content"
69
- className={cn("px-6", className)}
70
- {...props}
71
- />
72
- )
73
- }
74
-
75
- function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
76
- return (
77
- <div
78
- data-slot="card-footer"
79
- className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
80
- {...props}
81
- />
82
- )
83
- }
84
-
85
- export {
86
- Card,
87
- CardHeader,
88
- CardFooter,
89
- CardTitle,
90
- CardAction,
91
- CardDescription,
92
- CardContent,
93
- }
@@ -1,34 +0,0 @@
1
- // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
- "use client"
3
-
4
- import { Collapsible as CollapsiblePrimitive } from "radix-ui"
5
-
6
- function Collapsible({
7
- ...props
8
- }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
9
- return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
10
- }
11
-
12
- function CollapsibleTrigger({
13
- ...props
14
- }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
15
- return (
16
- <CollapsiblePrimitive.CollapsibleTrigger
17
- data-slot="collapsible-trigger"
18
- {...props}
19
- />
20
- )
21
- }
22
-
23
- function CollapsibleContent({
24
- ...props
25
- }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
26
- return (
27
- <CollapsiblePrimitive.CollapsibleContent
28
- data-slot="collapsible-content"
29
- {...props}
30
- />
31
- )
32
- }
33
-
34
- export { Collapsible, CollapsibleTrigger, CollapsibleContent }