@cosmicdrift/kumiko-renderer-web 0.193.1 → 0.194.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__/create-app.test.tsx +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/clamp.ts +3 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +3 -0
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +90 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +40 -2
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// viewport, so the `hidden md:block` / `md:hidden` classes stay on the
|
|
12
12
|
// wrapper divs too: on a phone that first (wrong) render is desktop but
|
|
13
13
|
// CSS-hidden, not a visible flash of the wrong layout.
|
|
14
|
+
//
|
|
15
|
+
// ponytail: crossing the mobile/desktop breakpoint unmounts the active
|
|
16
|
+
// layout and mounts the other, so an in-progress cell draft (uncommitted
|
|
17
|
+
// MoneyInput text, IME composition) and focus are lost — accepted trade-off
|
|
18
|
+
// for the single-mount-per-side fix above. Upgrade: lift per-cell draft
|
|
19
|
+
// state into a `draftsByCellId` map here that survives the layout swap.
|
|
14
20
|
|
|
15
21
|
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
16
22
|
import type {
|
|
@@ -19,7 +25,7 @@ import type {
|
|
|
19
25
|
EmbeddedListInputProps,
|
|
20
26
|
EmbeddedListTotal,
|
|
21
27
|
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
-
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
28
|
+
import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
23
29
|
import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from "lucide-react";
|
|
24
30
|
import {
|
|
25
31
|
type ClipboardEvent,
|
|
@@ -94,10 +100,11 @@ function formatTotalValue(
|
|
|
94
100
|
total: EmbeddedListTotal,
|
|
95
101
|
columns: readonly EmbeddedListColumn[],
|
|
96
102
|
currency: string,
|
|
103
|
+
locale: string,
|
|
97
104
|
): string {
|
|
98
105
|
const column = columns.find((c) => c.field === total.field);
|
|
99
|
-
if (column?.type === "money") return formatMoney(total.value, currency);
|
|
100
|
-
return total.value.toLocaleString();
|
|
106
|
+
if (column?.type === "money") return formatMoney(total.value, currency, locale);
|
|
107
|
+
return total.value.toLocaleString(locale);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
// Tab/newline-delimited clipboard text → 2D string grid. Not a regex
|
|
@@ -375,6 +382,7 @@ export function EmbeddedListInput({
|
|
|
375
382
|
// callers/tests that predate #1839) keep getting the same "EUR" this
|
|
376
383
|
// component always hardcoded.
|
|
377
384
|
const effectiveCurrency = currency ?? "EUR";
|
|
385
|
+
const resolvedLocale = useLocale().locale();
|
|
378
386
|
const isMobile = useIsMobile();
|
|
379
387
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
380
388
|
const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
|
|
@@ -419,6 +427,7 @@ export function EmbeddedListInput({
|
|
|
419
427
|
const handlePaste = (rowIndex: number, columnIndex: number) => {
|
|
420
428
|
return (event: ClipboardEvent<HTMLElement>): void => {
|
|
421
429
|
if (onPasteCells === undefined) return;
|
|
430
|
+
if (disabled === true) return;
|
|
422
431
|
const text = event.clipboardData?.getData("text") ?? "";
|
|
423
432
|
const grid = parsePasteGrid(text);
|
|
424
433
|
const isMultiCell = grid.length > 1 || (grid[0]?.length ?? 0) > 1;
|
|
@@ -596,7 +605,7 @@ export function EmbeddedListInput({
|
|
|
596
605
|
<div key={total.field} className="flex items-baseline gap-2">
|
|
597
606
|
<span className="text-muted-foreground">{total.label}</span>
|
|
598
607
|
<span className="font-medium tabular-nums">
|
|
599
|
-
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
608
|
+
{formatTotalValue(total, columns, effectiveCurrency, resolvedLocale)}
|
|
600
609
|
</span>
|
|
601
610
|
</div>
|
|
602
611
|
))}
|
|
@@ -699,7 +708,7 @@ export function EmbeddedListInput({
|
|
|
699
708
|
<div key={total.field} className="flex items-center justify-between">
|
|
700
709
|
<span className="text-muted-foreground">{total.label}</span>
|
|
701
710
|
<span className="font-medium tabular-nums">
|
|
702
|
-
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
711
|
+
{formatTotalValue(total, columns, effectiveCurrency, resolvedLocale)}
|
|
703
712
|
</span>
|
|
704
713
|
</div>
|
|
705
714
|
))}
|
|
@@ -19,6 +19,7 @@ export type FileUploadInputProps = {
|
|
|
19
19
|
readonly entityType?: string;
|
|
20
20
|
readonly fieldName?: string;
|
|
21
21
|
readonly imageVariant?: string;
|
|
22
|
+
readonly capture?: "environment" | "user";
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
|
|
@@ -37,6 +38,7 @@ export function FileUploadInput({
|
|
|
37
38
|
entityType,
|
|
38
39
|
fieldName,
|
|
39
40
|
imageVariant,
|
|
41
|
+
capture,
|
|
40
42
|
}: FileUploadInputProps): ReactNode {
|
|
41
43
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
44
|
const [uploading, setUploading] = useState(false);
|
|
@@ -105,6 +107,7 @@ export function FileUploadInput({
|
|
|
105
107
|
type="file"
|
|
106
108
|
className="hidden"
|
|
107
109
|
{...(acceptAttr !== undefined && { accept: acceptAttr })}
|
|
110
|
+
{...(capture !== undefined && { capture })}
|
|
108
111
|
onChange={(e) => void onPick(e)}
|
|
109
112
|
disabled={disabled}
|
|
110
113
|
/>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type TextProps,
|
|
38
38
|
useColumnRenderer,
|
|
39
39
|
useTranslation,
|
|
40
|
+
type WizardStepGroupProps,
|
|
40
41
|
WriteFailedError,
|
|
41
42
|
} from "@cosmicdrift/kumiko-renderer";
|
|
42
43
|
import { cva } from "class-variance-authority";
|
|
@@ -188,11 +189,16 @@ function DefaultBanner({
|
|
|
188
189
|
id,
|
|
189
190
|
}: BannerProps): ReactNode {
|
|
190
191
|
const isError = variant === "error";
|
|
192
|
+
// `id` is set when a <Field> wraps this Banner as its control (see
|
|
193
|
+
// BannerProps.id). A <div> isn't labelable, so a <label htmlFor> pointing
|
|
194
|
+
// at it is inert for screen readers — role="group" + aria-labelledby is
|
|
195
|
+
// the actual mechanism, pointed at the label id Field emits (fieldLabelId).
|
|
196
|
+
const bannerRole = id !== undefined ? "group" : isError ? "alert" : undefined;
|
|
191
197
|
const banner = (
|
|
192
198
|
<div
|
|
193
|
-
|
|
199
|
+
role={bannerRole}
|
|
200
|
+
{...(id !== undefined && { "aria-labelledby": fieldLabelId(id) })}
|
|
194
201
|
data-testid={testId}
|
|
195
|
-
role={isError ? "alert" : undefined}
|
|
196
202
|
data-variant={variant}
|
|
197
203
|
className={cn(
|
|
198
204
|
"relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
|
|
@@ -213,6 +219,13 @@ function DefaultBanner({
|
|
|
213
219
|
|
|
214
220
|
// ---- Field (Label + Error) ----
|
|
215
221
|
|
|
222
|
+
// Matches DefaultBanner's aria-labelledby target: a non-labelable control
|
|
223
|
+
// (e.g. Banner's <div>) can't take a real htmlFor, so Field always also
|
|
224
|
+
// gives its label a stable id that such controls can point to.
|
|
225
|
+
function fieldLabelId(id: string): string {
|
|
226
|
+
return `${id}-label`;
|
|
227
|
+
}
|
|
228
|
+
|
|
216
229
|
function DefaultField({
|
|
217
230
|
id,
|
|
218
231
|
label,
|
|
@@ -229,6 +242,7 @@ function DefaultField({
|
|
|
229
242
|
const hasError = issues !== undefined && issues.length > 0;
|
|
230
243
|
const labelEl = (
|
|
231
244
|
<UiLabel
|
|
245
|
+
id={fieldLabelId(id)}
|
|
232
246
|
htmlFor={id}
|
|
233
247
|
className={cn(
|
|
234
248
|
hasError ? "text-destructive" : "text-foreground",
|
|
@@ -236,7 +250,11 @@ function DefaultField({
|
|
|
236
250
|
)}
|
|
237
251
|
>
|
|
238
252
|
{label}
|
|
239
|
-
{required === true &&
|
|
253
|
+
{required === true && (
|
|
254
|
+
<span data-required className="ml-0.5 text-destructive">
|
|
255
|
+
*
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
240
258
|
</UiLabel>
|
|
241
259
|
);
|
|
242
260
|
const errorsEl = hasError ? (
|
|
@@ -435,6 +453,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
435
453
|
{...(props.entityType !== undefined && { entityType: props.entityType })}
|
|
436
454
|
{...(props.fieldName !== undefined && { fieldName: props.fieldName })}
|
|
437
455
|
{...(props.imageVariant !== undefined && { imageVariant: props.imageVariant })}
|
|
456
|
+
{...(props.capture !== undefined && { capture: props.capture })}
|
|
438
457
|
/>
|
|
439
458
|
);
|
|
440
459
|
case "date":
|
|
@@ -554,6 +573,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
554
573
|
return (
|
|
555
574
|
<Textarea
|
|
556
575
|
{...common}
|
|
576
|
+
readOnly={props.readOnly}
|
|
557
577
|
value={props.value}
|
|
558
578
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
|
|
559
579
|
rows={props.rows ?? 4}
|
|
@@ -650,6 +670,7 @@ function DefaultDataTable({
|
|
|
650
670
|
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
651
671
|
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
652
672
|
// Sticky würde mit der Topbar konkurrieren.
|
|
673
|
+
const hasTableActions = rowActions !== undefined && rowActions.length > 0;
|
|
653
674
|
const tableContent =
|
|
654
675
|
rows.length === 0 ? (
|
|
655
676
|
<div
|
|
@@ -665,18 +686,86 @@ function DefaultDataTable({
|
|
|
665
686
|
// Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
|
|
666
687
|
<div className="overflow-hidden rounded-lg border bg-card">
|
|
667
688
|
<Table data-testid={testId}>
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
689
|
+
<TableHeader className="bg-muted">
|
|
690
|
+
<TableRow className="hover:bg-transparent">
|
|
691
|
+
{columns.map((col) => (
|
|
692
|
+
<SortableHeader
|
|
693
|
+
key={col.field}
|
|
694
|
+
field={col.field}
|
|
695
|
+
label={col.label}
|
|
696
|
+
sortable={col.sortable === true}
|
|
697
|
+
highlighted={col.highlighted === true}
|
|
698
|
+
{...(sort !== undefined && sort !== null && { sort })}
|
|
699
|
+
{...(onSortChange !== undefined && { onSortChange })}
|
|
700
|
+
/>
|
|
701
|
+
))}
|
|
702
|
+
{hasTableActions && (
|
|
703
|
+
<TableHead
|
|
704
|
+
data-testid="column-actions"
|
|
705
|
+
// sticky right-0 + bg-muted (= header tone) so the action
|
|
706
|
+
// column stays at the right edge during horizontal scroll.
|
|
707
|
+
// No border-l: a permanent divider looks heavy; the sticky
|
|
708
|
+
// bg already sets the column apart during scroll anyway.
|
|
709
|
+
className="sticky right-0 z-10 w-px bg-muted text-right text-muted-foreground"
|
|
710
|
+
aria-label="Actions"
|
|
711
|
+
/>
|
|
712
|
+
)}
|
|
713
|
+
</TableRow>
|
|
714
|
+
</TableHeader>
|
|
715
|
+
<TableBody>
|
|
716
|
+
{rows.map((row) => (
|
|
717
|
+
<TableRow
|
|
718
|
+
key={row.id}
|
|
719
|
+
data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
|
|
720
|
+
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
721
|
+
className={cn(onRowClick !== undefined && "cursor-pointer")}
|
|
722
|
+
>
|
|
723
|
+
{columns.map((col) => (
|
|
724
|
+
<TableCell
|
|
725
|
+
key={col.field}
|
|
726
|
+
data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
|
|
727
|
+
data-highlighted={col.highlighted === true ? "true" : undefined}
|
|
728
|
+
// Cells truncate long values with ellipsis instead of
|
|
729
|
+
// wrapping — lists stay single-line + scannable (Linear
|
|
730
|
+
// pattern). max-w-xs gives a sensible default upper
|
|
731
|
+
// bound; the table container scrolls horizontally
|
|
732
|
+
// if the sum of the columns gets too wide.
|
|
733
|
+
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
734
|
+
title={cellTitle(row.values[col.field])}
|
|
735
|
+
>
|
|
736
|
+
<DataTableCell
|
|
737
|
+
value={row.values[col.field]}
|
|
738
|
+
row={row.values}
|
|
739
|
+
field={col.field}
|
|
740
|
+
type={col.type}
|
|
741
|
+
renderer={col.renderer}
|
|
742
|
+
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
743
|
+
{...(onCellChange !== undefined && {
|
|
744
|
+
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
745
|
+
})}
|
|
746
|
+
/>
|
|
747
|
+
</TableCell>
|
|
748
|
+
))}
|
|
749
|
+
{hasTableActions && (
|
|
750
|
+
<TableCell
|
|
751
|
+
data-testid={getCellTestId?.(row, "actions") ?? `cell-${row.id}-actions`}
|
|
752
|
+
// Sticky-right so the actions stay visible on the right
|
|
753
|
+
// edge during horizontal scroll. bg-background sets the
|
|
754
|
+
// column apart during scroll — no border-l (divider too heavy).
|
|
755
|
+
className="sticky right-0 z-10 bg-background text-right"
|
|
756
|
+
// Action-cell events must not trigger the row click/activation
|
|
757
|
+
// (typically "Open Detail" — the user wanted the action,
|
|
758
|
+
// not navigation). We stopPropagation for mouse and
|
|
759
|
+
// keyboard so a11y stays consistent.
|
|
760
|
+
onClick={(e) => e.stopPropagation()}
|
|
761
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
762
|
+
>
|
|
763
|
+
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
764
|
+
</TableCell>
|
|
765
|
+
)}
|
|
766
|
+
</TableRow>
|
|
767
|
+
))}
|
|
768
|
+
</TableBody>
|
|
680
769
|
</Table>
|
|
681
770
|
</div>
|
|
682
771
|
);
|
|
@@ -767,105 +856,6 @@ function DefaultDataTable({
|
|
|
767
856
|
);
|
|
768
857
|
}
|
|
769
858
|
|
|
770
|
-
function tableInner(
|
|
771
|
-
columns: DataTableProps["columns"],
|
|
772
|
-
rows: DataTableProps["rows"],
|
|
773
|
-
onRowClick?: DataTableProps["onRowClick"],
|
|
774
|
-
sort?: DataTableProps["sort"],
|
|
775
|
-
onSortChange?: DataTableProps["onSortChange"],
|
|
776
|
-
rowActions?: DataTableProps["rowActions"],
|
|
777
|
-
rowActionMode?: DataTableProps["rowActionMode"],
|
|
778
|
-
onCellChange?: DataTableProps["onCellChange"],
|
|
779
|
-
getRowTestId?: DataTableProps["getRowTestId"],
|
|
780
|
-
getCellTestId?: DataTableProps["getCellTestId"],
|
|
781
|
-
): ReactNode {
|
|
782
|
-
const hasActions = rowActions !== undefined && rowActions.length > 0;
|
|
783
|
-
return (
|
|
784
|
-
<>
|
|
785
|
-
<TableHeader className="bg-muted">
|
|
786
|
-
<TableRow className="hover:bg-transparent">
|
|
787
|
-
{columns.map((col) => (
|
|
788
|
-
<SortableHeader
|
|
789
|
-
key={col.field}
|
|
790
|
-
field={col.field}
|
|
791
|
-
label={col.label}
|
|
792
|
-
sortable={col.sortable === true}
|
|
793
|
-
highlighted={col.highlighted === true}
|
|
794
|
-
{...(sort !== undefined && sort !== null && { sort })}
|
|
795
|
-
{...(onSortChange !== undefined && { onSortChange })}
|
|
796
|
-
/>
|
|
797
|
-
))}
|
|
798
|
-
{hasActions && (
|
|
799
|
-
<TableHead
|
|
800
|
-
data-testid="column-actions"
|
|
801
|
-
// sticky right-0 + bg-muted (= Header-Ton) damit die Action-
|
|
802
|
-
// Spalte beim horizontalen Scroll am rechten Rand bleibt. Kein
|
|
803
|
-
// border-l: der ständige Trenner wirkt schwer; die sticky-bg
|
|
804
|
-
// grenzt die Spalte beim Scroll ohnehin ab.
|
|
805
|
-
className="sticky right-0 z-10 w-px bg-muted text-right text-muted-foreground"
|
|
806
|
-
aria-label="Actions"
|
|
807
|
-
/>
|
|
808
|
-
)}
|
|
809
|
-
</TableRow>
|
|
810
|
-
</TableHeader>
|
|
811
|
-
<TableBody>
|
|
812
|
-
{rows.map((row) => (
|
|
813
|
-
<TableRow
|
|
814
|
-
key={row.id}
|
|
815
|
-
data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
|
|
816
|
-
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
817
|
-
className={cn(onRowClick !== undefined && "cursor-pointer")}
|
|
818
|
-
>
|
|
819
|
-
{columns.map((col) => (
|
|
820
|
-
<TableCell
|
|
821
|
-
key={col.field}
|
|
822
|
-
data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
|
|
823
|
-
data-highlighted={col.highlighted === true ? "true" : undefined}
|
|
824
|
-
// Cells truncaten lange Werte mit ellipsis statt umzu-
|
|
825
|
-
// brechen — Lists bleiben einzeilig + scannbar (Linear-
|
|
826
|
-
// Pattern). max-w-xs gibt eine vernünftige Default-
|
|
827
|
-
// Obergrenze; der Table-Container scrollt horizontal
|
|
828
|
-
// falls die Summe der Spalten zu breit wird.
|
|
829
|
-
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
830
|
-
title={cellTitle(row.values[col.field])}
|
|
831
|
-
>
|
|
832
|
-
<DataTableCell
|
|
833
|
-
value={row.values[col.field]}
|
|
834
|
-
row={row.values}
|
|
835
|
-
field={col.field}
|
|
836
|
-
type={col.type}
|
|
837
|
-
renderer={col.renderer}
|
|
838
|
-
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
839
|
-
{...(onCellChange !== undefined && {
|
|
840
|
-
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
841
|
-
})}
|
|
842
|
-
/>
|
|
843
|
-
</TableCell>
|
|
844
|
-
))}
|
|
845
|
-
{hasActions && (
|
|
846
|
-
<TableCell
|
|
847
|
-
data-testid={`cell-${row.id}-actions`}
|
|
848
|
-
// Sticky-right damit beim horizontalen Scroll die Actions
|
|
849
|
-
// am rechten Rand sichtbar bleiben. bg-background grenzt die
|
|
850
|
-
// Spalte beim Scroll ab — kein border-l (Trenner zu schwer).
|
|
851
|
-
className="sticky right-0 z-10 bg-background text-right"
|
|
852
|
-
// Action-Cell-Events dürfen nicht den Row-Click/Activation
|
|
853
|
-
// triggern (typisch "Open Detail" — der User wollte ja die
|
|
854
|
-
// Action, nicht navigieren). Wir stopPropagation für Mouse
|
|
855
|
-
// UND Keyboard, damit a11y konsistent ist.
|
|
856
|
-
onClick={(e) => e.stopPropagation()}
|
|
857
|
-
onKeyDown={(e) => e.stopPropagation()}
|
|
858
|
-
>
|
|
859
|
-
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
860
|
-
</TableCell>
|
|
861
|
-
)}
|
|
862
|
-
</TableRow>
|
|
863
|
-
))}
|
|
864
|
-
</TableBody>
|
|
865
|
-
</>
|
|
866
|
-
);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
859
|
// RowActionsCell — rendert die Row-Actions je nach mode:
|
|
870
860
|
// - "adaptive" (Default): ≤2 sichtbare Actions inline (rechtsbündig),
|
|
871
861
|
// >2 als Kebab-Dropdown.
|
|
@@ -1666,7 +1656,7 @@ function DefaultForm({
|
|
|
1666
1656
|
// `overflow-hidden` (only transform/filter/contain ancestors trap
|
|
1667
1657
|
// it, confirmed against AppLayout/SidebarInset — neither sets those).
|
|
1668
1658
|
stickyActions === true &&
|
|
1669
|
-
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-
|
|
1659
|
+
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-4",
|
|
1670
1660
|
)}
|
|
1671
1661
|
>
|
|
1672
1662
|
{actions}
|
|
@@ -1722,7 +1712,6 @@ function DefaultSection({
|
|
|
1722
1712
|
actions,
|
|
1723
1713
|
variant = "default",
|
|
1724
1714
|
testId,
|
|
1725
|
-
hidden,
|
|
1726
1715
|
}: SectionProps): ReactNode {
|
|
1727
1716
|
const insideForm = useContext(InsideFormContext);
|
|
1728
1717
|
|
|
@@ -1758,11 +1747,9 @@ function DefaultSection({
|
|
|
1758
1747
|
return (
|
|
1759
1748
|
<section
|
|
1760
1749
|
data-testid={testId}
|
|
1761
|
-
hidden={hidden}
|
|
1762
1750
|
className={cn(
|
|
1763
1751
|
"flex flex-col gap-4 px-6 py-6",
|
|
1764
1752
|
variant === "destructive" && "border-l-2 border-destructive/40",
|
|
1765
|
-
hidden && "hidden",
|
|
1766
1753
|
)}
|
|
1767
1754
|
>
|
|
1768
1755
|
{header}
|
|
@@ -1789,12 +1776,10 @@ function DefaultSection({
|
|
|
1789
1776
|
return (
|
|
1790
1777
|
<div
|
|
1791
1778
|
data-testid={testId}
|
|
1792
|
-
hidden={hidden}
|
|
1793
1779
|
className={cn(
|
|
1794
1780
|
cardSurface(),
|
|
1795
1781
|
"overflow-hidden",
|
|
1796
1782
|
variant === "destructive" && "border-destructive/40",
|
|
1797
|
-
hidden && "hidden",
|
|
1798
1783
|
)}
|
|
1799
1784
|
>
|
|
1800
1785
|
<div className="flex flex-col gap-4 px-6 py-6">
|
|
@@ -1929,6 +1914,14 @@ function DefaultStepBar({
|
|
|
1929
1914
|
);
|
|
1930
1915
|
}
|
|
1931
1916
|
|
|
1917
|
+
function DefaultWizardStepGroup({ hidden, children }: WizardStepGroupProps): ReactNode {
|
|
1918
|
+
return (
|
|
1919
|
+
<fieldset disabled={hidden} hidden={hidden} className={hidden ? undefined : "contents"}>
|
|
1920
|
+
{children}
|
|
1921
|
+
</fieldset>
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1932
1925
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1933
1926
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1934
1927
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -2024,4 +2017,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2024
2017
|
Link: DefaultLink,
|
|
2025
2018
|
Progress: DefaultProgress,
|
|
2026
2019
|
StepBar: DefaultStepBar,
|
|
2020
|
+
WizardStepGroup: DefaultWizardStepGroup,
|
|
2027
2021
|
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { currencyDecimals } from "@cosmicdrift/kumiko-headless";
|
|
18
18
|
import { Minus, Plus } from "lucide-react";
|
|
19
|
-
import { type ReactNode,
|
|
19
|
+
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
|
20
20
|
import { cn } from "../lib/cn";
|
|
21
21
|
|
|
22
22
|
// Re-exported for backward compat — callers used to import this from here
|
|
@@ -72,8 +72,7 @@ export function MoneyInput({
|
|
|
72
72
|
const major = value === "" ? null : value / factor;
|
|
73
73
|
const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
|
|
74
74
|
|
|
75
|
-
// Edit-Mode: Decimal-String ohne Tausender-Trenner.
|
|
76
|
-
// Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
|
|
75
|
+
// Edit-Mode: Decimal-String ohne Tausender-Trenner.
|
|
77
76
|
const toEditable = (m: number | null): string =>
|
|
78
77
|
m === null
|
|
79
78
|
? ""
|
|
@@ -83,8 +82,6 @@ export function MoneyInput({
|
|
|
83
82
|
useGrouping: false,
|
|
84
83
|
});
|
|
85
84
|
|
|
86
|
-
const editable = focused ? draft : toEditable(major);
|
|
87
|
-
|
|
88
85
|
const handleFocus = (): void => {
|
|
89
86
|
setDraft(toEditable(major));
|
|
90
87
|
setFocused(true);
|
|
@@ -102,7 +99,7 @@ export function MoneyInput({
|
|
|
102
99
|
// click position from the formatted view to the editable one, which has
|
|
103
100
|
// no well-defined equivalent once separators and the currency symbol
|
|
104
101
|
// are stripped.
|
|
105
|
-
|
|
102
|
+
useLayoutEffect(() => {
|
|
106
103
|
if (focused) inputRef.current?.select();
|
|
107
104
|
}, [focused]);
|
|
108
105
|
|
|
@@ -134,7 +131,7 @@ export function MoneyInput({
|
|
|
134
131
|
disabled={disabled}
|
|
135
132
|
aria-required={required}
|
|
136
133
|
aria-invalid={hasError === true ? true : undefined}
|
|
137
|
-
value={focused ?
|
|
134
|
+
value={focused ? draft : formatted}
|
|
138
135
|
onFocus={handleFocus}
|
|
139
136
|
onBlur={handleBlur}
|
|
140
137
|
onChange={(e) => setDraft(e.target.value)}
|
package/src/ui/sheet.tsx
CHANGED
|
@@ -48,6 +48,7 @@ function SheetOverlay({
|
|
|
48
48
|
function SheetContent({
|
|
49
49
|
className,
|
|
50
50
|
overlayClassName,
|
|
51
|
+
overlayStyle,
|
|
51
52
|
children,
|
|
52
53
|
side = "right",
|
|
53
54
|
showCloseButton = true,
|
|
@@ -56,10 +57,11 @@ function SheetContent({
|
|
|
56
57
|
side?: "top" | "right" | "bottom" | "left"
|
|
57
58
|
showCloseButton?: boolean
|
|
58
59
|
overlayClassName?: string
|
|
60
|
+
overlayStyle?: React.CSSProperties
|
|
59
61
|
}) {
|
|
60
62
|
return (
|
|
61
63
|
<SheetPortal>
|
|
62
|
-
<SheetOverlay className={overlayClassName} />
|
|
64
|
+
<SheetOverlay className={overlayClassName} style={overlayStyle} />
|
|
63
65
|
<SheetPrimitive.Content
|
|
64
66
|
data-slot="sheet-content"
|
|
65
67
|
className={cn(
|