@open-mercato/ui 0.5.1-develop.2912.8d7b1fef24 → 0.5.1-develop.2924.d13908516e
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +51 -4
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/crud/CollapsibleGroup.js +57 -32
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/crud/SortableGroupHandle.js +40 -0
- package/dist/backend/crud/SortableGroupHandle.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +56 -5
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +96 -0
- package/src/backend/crud/CollapsibleGroup.tsx +52 -30
- package/src/backend/crud/SortableGroupHandle.tsx +50 -0
- package/src/backend/crud/__tests__/SortableGroupHandle.test.tsx +87 -0
|
@@ -6,11 +6,14 @@ import { cn } from "@open-mercato/shared/lib/utils";
|
|
|
6
6
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
7
7
|
import { Button } from "../../primitives/button.js";
|
|
8
8
|
import { useGroupCollapse } from "./useGroupCollapse.js";
|
|
9
|
+
import { SortableGroupHandle, useSortableGroupHandle } from "./SortableGroupHandle.js";
|
|
9
10
|
const CollapsibleGroup = React.forwardRef(
|
|
10
11
|
function CollapsibleGroup2({ groupId, title, pageType, defaultExpanded = true, errorCount = 0, fieldCount, chevronPosition = "right", icon, children }, ref) {
|
|
11
12
|
const t = useT();
|
|
12
13
|
const { expanded, toggle, setExpanded, isHydrated } = useGroupCollapse(pageType, groupId, defaultExpanded);
|
|
13
14
|
const contentId = `collapsible-group-${groupId}`;
|
|
15
|
+
const sortableHandle = useSortableGroupHandle();
|
|
16
|
+
const showDragHandle = sortableHandle !== null;
|
|
14
17
|
React.useImperativeHandle(ref, () => ({
|
|
15
18
|
expand: () => setExpanded(true)
|
|
16
19
|
}), [setExpanded]);
|
|
@@ -30,6 +33,16 @@ const CollapsibleGroup = React.forwardRef(
|
|
|
30
33
|
fieldCount === 1 ? t("ui.collapsible.fieldSingular", "field") : t("ui.collapsible.fieldPlural", "fields")
|
|
31
34
|
] }) : null;
|
|
32
35
|
const errorBadge = errorCount > 0 ? /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive", children: errorCount === 1 ? t("ui.collapsible.errorSingular", "{{count}} error", { count: errorCount }) : t("ui.collapsible.errorPlural", "{{count}} errors", { count: errorCount }) }) : null;
|
|
36
|
+
const dragHandle = showDragHandle ? /* @__PURE__ */ jsx(
|
|
37
|
+
"span",
|
|
38
|
+
{
|
|
39
|
+
className: "inline-flex shrink-0 items-center",
|
|
40
|
+
onClick: (event) => event.stopPropagation(),
|
|
41
|
+
onPointerDown: (event) => event.stopPropagation(),
|
|
42
|
+
onKeyDown: (event) => event.stopPropagation(),
|
|
43
|
+
children: /* @__PURE__ */ jsx(SortableGroupHandle, { ariaLabel: t("ui.crud.dragHandle.aria", "Drag to reorder") })
|
|
44
|
+
}
|
|
45
|
+
) : null;
|
|
33
46
|
return /* @__PURE__ */ jsxs(
|
|
34
47
|
"div",
|
|
35
48
|
{
|
|
@@ -43,41 +56,53 @@ const CollapsibleGroup = React.forwardRef(
|
|
|
43
56
|
"data-persistence-hydrated": isHydrated ? "true" : "false",
|
|
44
57
|
"aria-hidden": isHydrated ? void 0 : true,
|
|
45
58
|
children: [
|
|
46
|
-
title && /* @__PURE__ */
|
|
47
|
-
|
|
59
|
+
title && /* @__PURE__ */ jsxs(
|
|
60
|
+
"div",
|
|
48
61
|
{
|
|
49
|
-
type: "button",
|
|
50
|
-
variant: "muted",
|
|
51
|
-
onClick: toggle,
|
|
52
62
|
className: cn(
|
|
53
|
-
"
|
|
54
|
-
chevronPosition === "left" ? "
|
|
63
|
+
"flex items-center gap-2 px-2 py-2",
|
|
64
|
+
chevronPosition === "left" ? "flex-row" : "flex-row"
|
|
55
65
|
),
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
children: [
|
|
67
|
+
dragHandle,
|
|
68
|
+
/* @__PURE__ */ jsx(
|
|
69
|
+
Button,
|
|
70
|
+
{
|
|
71
|
+
type: "button",
|
|
72
|
+
variant: "muted",
|
|
73
|
+
onClick: toggle,
|
|
74
|
+
className: cn(
|
|
75
|
+
"flex-1 px-2 py-1 text-sm font-medium hover:bg-accent/50 rounded-md",
|
|
76
|
+
chevronPosition === "left" ? "justify-start gap-2" : "justify-between"
|
|
77
|
+
),
|
|
78
|
+
"aria-expanded": expanded,
|
|
79
|
+
"aria-controls": contentId,
|
|
80
|
+
children: chevronPosition === "left" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
81
|
+
chevronIcon,
|
|
82
|
+
/* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
|
|
83
|
+
icon && /* @__PURE__ */ jsxs("span", { className: "relative shrink-0 text-muted-foreground", children: [
|
|
84
|
+
icon,
|
|
85
|
+
!expanded && errorCount > 0 && /* @__PURE__ */ jsx("span", { className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" })
|
|
86
|
+
] }),
|
|
87
|
+
/* @__PURE__ */ jsx("span", { children: title }),
|
|
88
|
+
fieldCountLabel,
|
|
89
|
+
errorBadge
|
|
90
|
+
] })
|
|
91
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
92
|
+
/* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
|
|
93
|
+
icon && /* @__PURE__ */ jsxs("span", { className: "relative shrink-0 text-muted-foreground", children: [
|
|
94
|
+
icon,
|
|
95
|
+
!expanded && errorCount > 0 && /* @__PURE__ */ jsx("span", { className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" })
|
|
96
|
+
] }),
|
|
97
|
+
/* @__PURE__ */ jsx("span", { children: title }),
|
|
98
|
+
fieldCountLabel,
|
|
99
|
+
errorBadge
|
|
100
|
+
] }),
|
|
101
|
+
chevronIcon
|
|
102
|
+
] })
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
]
|
|
81
106
|
}
|
|
82
107
|
),
|
|
83
108
|
/* @__PURE__ */ jsx(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/crud/CollapsibleGroup.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { useGroupCollapse } from './useGroupCollapse'\n\nexport interface CollapsibleGroupProps {\n groupId: string\n title?: string\n pageType: string\n defaultExpanded?: boolean\n errorCount?: number\n fieldCount?: number\n chevronPosition?: 'left' | 'right'\n icon?: React.ReactNode\n children: React.ReactNode\n}\n\nexport interface CollapsibleGroupHandle {\n expand: () => void\n}\n\nexport const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, CollapsibleGroupProps>(\n function CollapsibleGroup({ groupId, title, pageType, defaultExpanded = true, errorCount = 0, fieldCount, chevronPosition = 'right', icon, children }, ref) {\n const t = useT()\n const { expanded, toggle, setExpanded, isHydrated } = useGroupCollapse(pageType, groupId, defaultExpanded)\n const contentId = `collapsible-group-${groupId}`\n\n React.useImperativeHandle(ref, () => ({\n expand: () => setExpanded(true),\n }), [setExpanded])\n\n const chevronIcon = (\n <ChevronDown\n className={cn(\n 'size-4 shrink-0 motion-safe:transition-transform motion-safe:duration-200',\n expanded && 'rotate-180'\n )}\n />\n )\n\n const fieldCountLabel = typeof fieldCount === 'number' && fieldCount > 0 ? (\n <span className=\"text-xs font-normal text-muted-foreground\">\n \u00B7 {fieldCount} {fieldCount === 1\n ? t('ui.collapsible.fieldSingular', 'field')\n : t('ui.collapsible.fieldPlural', 'fields')}\n </span>\n ) : null\n\n const errorBadge = errorCount > 0 ? (\n <span className=\"inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive\">\n {errorCount === 1\n ? t('ui.collapsible.errorSingular', '{{count}} error', { count: errorCount })\n : t('ui.collapsible.errorPlural', '{{count}} errors', { count: errorCount })}\n </span>\n ) : null\n\n return (\n <div\n id={`collapsible-group-wrapper-${groupId}`}\n className={cn(\n 'rounded-lg border bg-card',\n !isHydrated && 'invisible',\n errorCount > 0 && 'border-destructive',\n )}\n data-collapsible-group-id={groupId}\n data-persistence-hydrated={isHydrated ? 'true' : 'false'}\n aria-hidden={isHydrated ? undefined : true}\n >\n {title && (\n <Button\n
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { useGroupCollapse } from './useGroupCollapse'\nimport { SortableGroupHandle, useSortableGroupHandle } from './SortableGroupHandle'\n\nexport interface CollapsibleGroupProps {\n groupId: string\n title?: string\n pageType: string\n defaultExpanded?: boolean\n errorCount?: number\n fieldCount?: number\n chevronPosition?: 'left' | 'right'\n icon?: React.ReactNode\n children: React.ReactNode\n}\n\nexport interface CollapsibleGroupHandle {\n expand: () => void\n}\n\nexport const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, CollapsibleGroupProps>(\n function CollapsibleGroup({ groupId, title, pageType, defaultExpanded = true, errorCount = 0, fieldCount, chevronPosition = 'right', icon, children }, ref) {\n const t = useT()\n const { expanded, toggle, setExpanded, isHydrated } = useGroupCollapse(pageType, groupId, defaultExpanded)\n const contentId = `collapsible-group-${groupId}`\n const sortableHandle = useSortableGroupHandle()\n const showDragHandle = sortableHandle !== null\n\n React.useImperativeHandle(ref, () => ({\n expand: () => setExpanded(true),\n }), [setExpanded])\n\n const chevronIcon = (\n <ChevronDown\n className={cn(\n 'size-4 shrink-0 motion-safe:transition-transform motion-safe:duration-200',\n expanded && 'rotate-180'\n )}\n />\n )\n\n const fieldCountLabel = typeof fieldCount === 'number' && fieldCount > 0 ? (\n <span className=\"text-xs font-normal text-muted-foreground\">\n \u00B7 {fieldCount} {fieldCount === 1\n ? t('ui.collapsible.fieldSingular', 'field')\n : t('ui.collapsible.fieldPlural', 'fields')}\n </span>\n ) : null\n\n const errorBadge = errorCount > 0 ? (\n <span className=\"inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive\">\n {errorCount === 1\n ? t('ui.collapsible.errorSingular', '{{count}} error', { count: errorCount })\n : t('ui.collapsible.errorPlural', '{{count}} errors', { count: errorCount })}\n </span>\n ) : null\n\n const dragHandle = showDragHandle ? (\n <span\n className=\"inline-flex shrink-0 items-center\"\n onClick={(event) => event.stopPropagation()}\n onPointerDown={(event) => event.stopPropagation()}\n onKeyDown={(event) => event.stopPropagation()}\n >\n <SortableGroupHandle ariaLabel={t('ui.crud.dragHandle.aria', 'Drag to reorder')} />\n </span>\n ) : null\n\n return (\n <div\n id={`collapsible-group-wrapper-${groupId}`}\n className={cn(\n 'rounded-lg border bg-card',\n !isHydrated && 'invisible',\n errorCount > 0 && 'border-destructive',\n )}\n data-collapsible-group-id={groupId}\n data-persistence-hydrated={isHydrated ? 'true' : 'false'}\n aria-hidden={isHydrated ? undefined : true}\n >\n {title && (\n <div\n className={cn(\n 'flex items-center gap-2 px-2 py-2',\n chevronPosition === 'left' ? 'flex-row' : 'flex-row',\n )}\n >\n {dragHandle}\n <Button\n type=\"button\"\n variant=\"muted\"\n onClick={toggle}\n className={cn(\n 'flex-1 px-2 py-1 text-sm font-medium hover:bg-accent/50 rounded-md',\n chevronPosition === 'left' ? 'justify-start gap-2' : 'justify-between',\n )}\n aria-expanded={expanded}\n aria-controls={contentId}\n >\n {chevronPosition === 'left' ? (\n <>\n {chevronIcon}\n <span className=\"flex items-center gap-2\">\n {icon && <span className=\"relative shrink-0 text-muted-foreground\">{icon}{!expanded && errorCount > 0 && <span className=\"absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive\" />}</span>}\n <span>{title}</span>\n {fieldCountLabel}\n {errorBadge}\n </span>\n </>\n ) : (\n <>\n <span className=\"flex items-center gap-2\">\n {icon && <span className=\"relative shrink-0 text-muted-foreground\">{icon}{!expanded && errorCount > 0 && <span className=\"absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive\" />}</span>}\n <span>{title}</span>\n {fieldCountLabel}\n {errorBadge}\n </span>\n {chevronIcon}\n </>\n )}\n </Button>\n </div>\n )}\n <div\n id={contentId}\n className={cn(\n 'motion-safe:transition-all motion-safe:duration-200 overflow-hidden',\n expanded ? 'max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'\n )}\n >\n <div className=\"px-4 py-3\">\n {children}\n </div>\n </div>\n </div>\n )\n }\n)\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["CollapsibleGroup"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { GripVertical } from "lucide-react";
|
|
5
|
+
import { cn } from "@open-mercato/shared/lib/utils";
|
|
6
|
+
import { IconButton } from "../../primitives/icon-button.js";
|
|
7
|
+
const SortableGroupHandleContext = React.createContext(null);
|
|
8
|
+
const SortableGroupHandleProvider = SortableGroupHandleContext.Provider;
|
|
9
|
+
function useSortableGroupHandle() {
|
|
10
|
+
return React.useContext(SortableGroupHandleContext);
|
|
11
|
+
}
|
|
12
|
+
function SortableGroupHandle({ ariaLabel, className }) {
|
|
13
|
+
const handle = useSortableGroupHandle();
|
|
14
|
+
if (!handle) return null;
|
|
15
|
+
const { ref, attributes, listeners, disabled } = handle;
|
|
16
|
+
return /* @__PURE__ */ jsx(
|
|
17
|
+
IconButton,
|
|
18
|
+
{
|
|
19
|
+
type: "button",
|
|
20
|
+
variant: "ghost",
|
|
21
|
+
size: "xs",
|
|
22
|
+
ref,
|
|
23
|
+
"aria-label": ariaLabel,
|
|
24
|
+
disabled,
|
|
25
|
+
className: cn(
|
|
26
|
+
"cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm",
|
|
27
|
+
className
|
|
28
|
+
),
|
|
29
|
+
...attributes,
|
|
30
|
+
...listeners ?? {},
|
|
31
|
+
children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
SortableGroupHandle,
|
|
37
|
+
SortableGroupHandleProvider,
|
|
38
|
+
useSortableGroupHandle
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=SortableGroupHandle.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/crud/SortableGroupHandle.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { GripVertical } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { IconButton } from '../../primitives/icon-button'\n\nexport type SortableGroupHandleProps = {\n ref: (node: HTMLElement | null) => void\n attributes: Record<string, unknown>\n listeners: Record<string, unknown> | undefined\n isDragging: boolean\n disabled: boolean\n}\n\nconst SortableGroupHandleContext = React.createContext<SortableGroupHandleProps | null>(null)\n\nexport const SortableGroupHandleProvider = SortableGroupHandleContext.Provider\n\nexport function useSortableGroupHandle(): SortableGroupHandleProps | null {\n return React.useContext(SortableGroupHandleContext)\n}\n\nexport interface SortableGroupHandleButtonProps {\n ariaLabel: string\n className?: string\n}\n\nexport function SortableGroupHandle({ ariaLabel, className }: SortableGroupHandleButtonProps) {\n const handle = useSortableGroupHandle()\n if (!handle) return null\n const { ref, attributes, listeners, disabled } = handle\n return (\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n ref={ref as unknown as React.Ref<HTMLButtonElement>}\n aria-label={ariaLabel}\n disabled={disabled}\n className={cn(\n 'cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm',\n className,\n )}\n {...(attributes as Record<string, unknown>)}\n {...((listeners ?? {}) as Record<string, unknown>)}\n >\n <GripVertical className=\"size-4\" />\n </IconButton>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AA8CM;AA7CN,YAAY,WAAW;AACvB,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAU3B,MAAM,6BAA6B,MAAM,cAA+C,IAAI;AAErF,MAAM,8BAA8B,2BAA2B;AAE/D,SAAS,yBAA0D;AACxE,SAAO,MAAM,WAAW,0BAA0B;AACpD;AAOO,SAAS,oBAAoB,EAAE,WAAW,UAAU,GAAmC;AAC5F,QAAM,SAAS,uBAAuB;AACtC,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,EAAE,KAAK,YAAY,WAAW,SAAS,IAAI;AACjD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL;AAAA,MACA,cAAY;AAAA,MACZ;AAAA,MACA,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAI;AAAA,MACJ,GAAK,aAAa,CAAC;AAAA,MAEpB,8BAAC,gBAAa,WAAU,UAAS;AAAA;AAAA,EACnC;AAEJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.5.1-develop.
|
|
3
|
+
"version": "0.5.1-develop.2924.d13908516e",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -132,12 +132,12 @@
|
|
|
132
132
|
"recharts": "^3.8.1"
|
|
133
133
|
},
|
|
134
134
|
"peerDependencies": {
|
|
135
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
135
|
+
"@open-mercato/shared": "0.5.1-develop.2924.d13908516e",
|
|
136
136
|
"react": ">=18.0.0",
|
|
137
137
|
"react-dom": ">=18.0.0"
|
|
138
138
|
},
|
|
139
139
|
"devDependencies": {
|
|
140
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
140
|
+
"@open-mercato/shared": "0.5.1-develop.2924.d13908516e",
|
|
141
141
|
"@testing-library/dom": "^10.4.1",
|
|
142
142
|
"@testing-library/jest-dom": "^6.9.1",
|
|
143
143
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -3,7 +3,17 @@ import * as React from 'react'
|
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { z } from 'zod'
|
|
5
5
|
import { useRouter } from 'next/navigation'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DndContext,
|
|
8
|
+
closestCenter,
|
|
9
|
+
PointerSensor,
|
|
10
|
+
KeyboardSensor,
|
|
11
|
+
useSensor,
|
|
12
|
+
useSensors,
|
|
13
|
+
type DragEndEvent,
|
|
14
|
+
type Activators,
|
|
15
|
+
} from '@dnd-kit/core'
|
|
16
|
+
import type { KeyboardSensorOptions } from '@dnd-kit/core'
|
|
7
17
|
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable'
|
|
8
18
|
import { CSS } from '@dnd-kit/utilities'
|
|
9
19
|
import { DataLoader } from '../primitives/DataLoader'
|
|
@@ -76,6 +86,7 @@ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
|
76
86
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
77
87
|
import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
|
|
78
88
|
import { CollapsibleGroup, type CollapsibleGroupHandle } from './crud/CollapsibleGroup'
|
|
89
|
+
import { SortableGroupHandleProvider, type SortableGroupHandleProps } from './crud/SortableGroupHandle'
|
|
79
90
|
import { useGroupOrder } from './crud/useGroupOrder'
|
|
80
91
|
import { InjectedField } from './injection/InjectedField'
|
|
81
92
|
import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
|
|
@@ -92,6 +103,31 @@ const CRUDFORM_EXTENDED_EVENTS_ENABLED = parseBooleanWithDefault(
|
|
|
92
103
|
true,
|
|
93
104
|
)
|
|
94
105
|
|
|
106
|
+
function isFormControlTarget(target: EventTarget | null): boolean {
|
|
107
|
+
if (!target || typeof (target as Element).tagName !== 'string') return false
|
|
108
|
+
const el = target as HTMLElement
|
|
109
|
+
const tag = el.tagName
|
|
110
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
111
|
+
if (el.isContentEditable) return true
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveActivatorEventTarget(event: React.SyntheticEvent | Event): EventTarget | null {
|
|
116
|
+
const native = (event as React.SyntheticEvent).nativeEvent
|
|
117
|
+
if (native && typeof (native as Event).target !== 'undefined') return (native as Event).target
|
|
118
|
+
return (event as Event).target ?? null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class GuardedKeyboardSensor extends KeyboardSensor {
|
|
122
|
+
static activators: Activators<KeyboardSensorOptions> = KeyboardSensor.activators.map((activator) => ({
|
|
123
|
+
eventName: activator.eventName,
|
|
124
|
+
handler: (event, options, context) => {
|
|
125
|
+
if (isFormControlTarget(resolveActivatorEventTarget(event))) return false
|
|
126
|
+
return activator.handler(event, options, context)
|
|
127
|
+
},
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
function resolveInternalNavigationTarget(target: string | URL | null | undefined): string | null {
|
|
96
132
|
if (typeof window === 'undefined' || target == null) return null
|
|
97
133
|
try {
|
|
@@ -426,16 +462,31 @@ class FieldDefinitionsManagerErrorBoundary extends React.Component<
|
|
|
426
462
|
}
|
|
427
463
|
|
|
428
464
|
function SortableGroupItem({ id, children, disabled }: { id: string; children: React.ReactNode; disabled?: boolean }) {
|
|
429
|
-
const {
|
|
465
|
+
const {
|
|
466
|
+
attributes,
|
|
467
|
+
listeners,
|
|
468
|
+
setNodeRef,
|
|
469
|
+
setActivatorNodeRef,
|
|
470
|
+
transform,
|
|
471
|
+
transition,
|
|
472
|
+
isDragging,
|
|
473
|
+
} = useSortable({ id, disabled })
|
|
430
474
|
const style: React.CSSProperties = {
|
|
431
475
|
transform: CSS.Transform.toString(transform),
|
|
432
476
|
transition,
|
|
433
477
|
opacity: isDragging ? 0.5 : 1,
|
|
434
478
|
position: 'relative' as const,
|
|
435
479
|
}
|
|
480
|
+
const handleProps = React.useMemo<SortableGroupHandleProps>(() => ({
|
|
481
|
+
ref: setActivatorNodeRef,
|
|
482
|
+
attributes: attributes as unknown as Record<string, unknown>,
|
|
483
|
+
listeners: listeners as unknown as Record<string, unknown> | undefined,
|
|
484
|
+
isDragging,
|
|
485
|
+
disabled: !!disabled,
|
|
486
|
+
}), [setActivatorNodeRef, attributes, listeners, isDragging, disabled])
|
|
436
487
|
return (
|
|
437
|
-
<div ref={setNodeRef} style={style}
|
|
438
|
-
{children}
|
|
488
|
+
<div ref={setNodeRef} style={style}>
|
|
489
|
+
<SortableGroupHandleProvider value={handleProps}>{children}</SortableGroupHandleProvider>
|
|
439
490
|
</div>
|
|
440
491
|
)
|
|
441
492
|
}
|
|
@@ -1668,7 +1719,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1668
1719
|
)
|
|
1669
1720
|
const sortableSensors = useSensors(
|
|
1670
1721
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
1671
|
-
useSensor(
|
|
1722
|
+
useSensor(GuardedKeyboardSensor),
|
|
1672
1723
|
)
|
|
1673
1724
|
const handleGroupDragEnd = React.useCallback((event: DragEndEvent) => {
|
|
1674
1725
|
const { active, over } = event
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
const triggerInjectionEventMock = jest.fn(async (_event: string, data: Record<string, unknown>) => ({
|
|
5
|
+
ok: true,
|
|
6
|
+
data,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('next/navigation', () => ({
|
|
10
|
+
useRouter: () => ({ push: () => {} }),
|
|
11
|
+
usePathname: () => '/',
|
|
12
|
+
useSearchParams: () => new URLSearchParams(),
|
|
13
|
+
}))
|
|
14
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
15
|
+
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
16
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
InjectionSpot: () => null,
|
|
19
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
20
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerInjectionEventMock }),
|
|
21
|
+
}))
|
|
22
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import * as React from 'react'
|
|
28
|
+
import { fireEvent } from '@testing-library/react'
|
|
29
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
30
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
31
|
+
|
|
32
|
+
const fields: CrudField[] = [
|
|
33
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
34
|
+
{ id: 'note', label: 'Note', type: 'text' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const groups: CrudFormGroup[] = [
|
|
38
|
+
{ id: 'main', title: 'Main', fields: ['name'], column: 1 },
|
|
39
|
+
{ id: 'extra', title: 'Extra', fields: ['note'], column: 1 },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
describe('CrudForm sortable groups', () => {
|
|
43
|
+
it('renders a drag handle button with the expected aria-label when sortable + collapsible', () => {
|
|
44
|
+
const { container } = renderWithProviders(
|
|
45
|
+
<CrudForm
|
|
46
|
+
title="Form"
|
|
47
|
+
fields={fields}
|
|
48
|
+
groups={groups}
|
|
49
|
+
onSubmit={() => {}}
|
|
50
|
+
sortableGroups={true}
|
|
51
|
+
collapsibleGroups={true}
|
|
52
|
+
/>,
|
|
53
|
+
)
|
|
54
|
+
const handles = container.querySelectorAll('button[aria-label="Drag to reorder"]')
|
|
55
|
+
expect(handles.length).toBe(2)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not render any drag handle when sortable is disabled', () => {
|
|
59
|
+
const { container } = renderWithProviders(
|
|
60
|
+
<CrudForm
|
|
61
|
+
title="Form"
|
|
62
|
+
fields={fields}
|
|
63
|
+
groups={groups}
|
|
64
|
+
onSubmit={() => {}}
|
|
65
|
+
collapsibleGroups={true}
|
|
66
|
+
/>,
|
|
67
|
+
)
|
|
68
|
+
const handles = container.querySelectorAll('button[aria-label="Drag to reorder"]')
|
|
69
|
+
expect(handles.length).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not greys out a sortable card when space is pressed from a focused input', () => {
|
|
73
|
+
const { container } = renderWithProviders(
|
|
74
|
+
<CrudForm
|
|
75
|
+
title="Form"
|
|
76
|
+
fields={fields}
|
|
77
|
+
groups={groups}
|
|
78
|
+
initialValues={{ name: '' }}
|
|
79
|
+
onSubmit={() => {}}
|
|
80
|
+
sortableGroups={true}
|
|
81
|
+
collapsibleGroups={true}
|
|
82
|
+
/>,
|
|
83
|
+
)
|
|
84
|
+
const input = container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
85
|
+
expect(input).not.toBeNull()
|
|
86
|
+
input.focus()
|
|
87
|
+
fireEvent.keyDown(input, { key: ' ', code: 'Space' })
|
|
88
|
+
const sortableHandles = container.querySelectorAll('button[aria-label="Drag to reorder"]')
|
|
89
|
+
expect(sortableHandles.length).toBeGreaterThan(0)
|
|
90
|
+
sortableHandles.forEach((handle) => {
|
|
91
|
+
const card = handle.closest('[style]') as HTMLElement | null
|
|
92
|
+
const style = card?.getAttribute('style') || ''
|
|
93
|
+
expect(style).not.toMatch(/opacity:\s*0\.5/)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -5,6 +5,7 @@ import { cn } from '@open-mercato/shared/lib/utils'
|
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Button } from '../../primitives/button'
|
|
7
7
|
import { useGroupCollapse } from './useGroupCollapse'
|
|
8
|
+
import { SortableGroupHandle, useSortableGroupHandle } from './SortableGroupHandle'
|
|
8
9
|
|
|
9
10
|
export interface CollapsibleGroupProps {
|
|
10
11
|
groupId: string
|
|
@@ -27,6 +28,8 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
27
28
|
const t = useT()
|
|
28
29
|
const { expanded, toggle, setExpanded, isHydrated } = useGroupCollapse(pageType, groupId, defaultExpanded)
|
|
29
30
|
const contentId = `collapsible-group-${groupId}`
|
|
31
|
+
const sortableHandle = useSortableGroupHandle()
|
|
32
|
+
const showDragHandle = sortableHandle !== null
|
|
30
33
|
|
|
31
34
|
React.useImperativeHandle(ref, () => ({
|
|
32
35
|
expand: () => setExpanded(true),
|
|
@@ -57,6 +60,17 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
57
60
|
</span>
|
|
58
61
|
) : null
|
|
59
62
|
|
|
63
|
+
const dragHandle = showDragHandle ? (
|
|
64
|
+
<span
|
|
65
|
+
className="inline-flex shrink-0 items-center"
|
|
66
|
+
onClick={(event) => event.stopPropagation()}
|
|
67
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
68
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
69
|
+
>
|
|
70
|
+
<SortableGroupHandle ariaLabel={t('ui.crud.dragHandle.aria', 'Drag to reorder')} />
|
|
71
|
+
</span>
|
|
72
|
+
) : null
|
|
73
|
+
|
|
60
74
|
return (
|
|
61
75
|
<div
|
|
62
76
|
id={`collapsible-group-wrapper-${groupId}`}
|
|
@@ -70,39 +84,47 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
70
84
|
aria-hidden={isHydrated ? undefined : true}
|
|
71
85
|
>
|
|
72
86
|
{title && (
|
|
73
|
-
<
|
|
74
|
-
type="button"
|
|
75
|
-
variant="muted"
|
|
76
|
-
onClick={toggle}
|
|
87
|
+
<div
|
|
77
88
|
className={cn(
|
|
78
|
-
'
|
|
79
|
-
chevronPosition === 'left' ? '
|
|
89
|
+
'flex items-center gap-2 px-2 py-2',
|
|
90
|
+
chevronPosition === 'left' ? 'flex-row' : 'flex-row',
|
|
80
91
|
)}
|
|
81
|
-
aria-expanded={expanded}
|
|
82
|
-
aria-controls={contentId}
|
|
83
92
|
>
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
{dragHandle}
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
variant="muted"
|
|
97
|
+
onClick={toggle}
|
|
98
|
+
className={cn(
|
|
99
|
+
'flex-1 px-2 py-1 text-sm font-medium hover:bg-accent/50 rounded-md',
|
|
100
|
+
chevronPosition === 'left' ? 'justify-start gap-2' : 'justify-between',
|
|
101
|
+
)}
|
|
102
|
+
aria-expanded={expanded}
|
|
103
|
+
aria-controls={contentId}
|
|
104
|
+
>
|
|
105
|
+
{chevronPosition === 'left' ? (
|
|
106
|
+
<>
|
|
107
|
+
{chevronIcon}
|
|
108
|
+
<span className="flex items-center gap-2">
|
|
109
|
+
{icon && <span className="relative shrink-0 text-muted-foreground">{icon}{!expanded && errorCount > 0 && <span className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" />}</span>}
|
|
110
|
+
<span>{title}</span>
|
|
111
|
+
{fieldCountLabel}
|
|
112
|
+
{errorBadge}
|
|
113
|
+
</span>
|
|
114
|
+
</>
|
|
115
|
+
) : (
|
|
116
|
+
<>
|
|
117
|
+
<span className="flex items-center gap-2">
|
|
118
|
+
{icon && <span className="relative shrink-0 text-muted-foreground">{icon}{!expanded && errorCount > 0 && <span className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" />}</span>}
|
|
119
|
+
<span>{title}</span>
|
|
120
|
+
{fieldCountLabel}
|
|
121
|
+
{errorBadge}
|
|
122
|
+
</span>
|
|
123
|
+
{chevronIcon}
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
106
128
|
)}
|
|
107
129
|
<div
|
|
108
130
|
id={contentId}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { GripVertical } from 'lucide-react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
6
|
+
|
|
7
|
+
export type SortableGroupHandleProps = {
|
|
8
|
+
ref: (node: HTMLElement | null) => void
|
|
9
|
+
attributes: Record<string, unknown>
|
|
10
|
+
listeners: Record<string, unknown> | undefined
|
|
11
|
+
isDragging: boolean
|
|
12
|
+
disabled: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SortableGroupHandleContext = React.createContext<SortableGroupHandleProps | null>(null)
|
|
16
|
+
|
|
17
|
+
export const SortableGroupHandleProvider = SortableGroupHandleContext.Provider
|
|
18
|
+
|
|
19
|
+
export function useSortableGroupHandle(): SortableGroupHandleProps | null {
|
|
20
|
+
return React.useContext(SortableGroupHandleContext)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SortableGroupHandleButtonProps {
|
|
24
|
+
ariaLabel: string
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SortableGroupHandle({ ariaLabel, className }: SortableGroupHandleButtonProps) {
|
|
29
|
+
const handle = useSortableGroupHandle()
|
|
30
|
+
if (!handle) return null
|
|
31
|
+
const { ref, attributes, listeners, disabled } = handle
|
|
32
|
+
return (
|
|
33
|
+
<IconButton
|
|
34
|
+
type="button"
|
|
35
|
+
variant="ghost"
|
|
36
|
+
size="xs"
|
|
37
|
+
ref={ref as unknown as React.Ref<HTMLButtonElement>}
|
|
38
|
+
aria-label={ariaLabel}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
className={cn(
|
|
41
|
+
'cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm',
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...(attributes as Record<string, unknown>)}
|
|
45
|
+
{...((listeners ?? {}) as Record<string, unknown>)}
|
|
46
|
+
>
|
|
47
|
+
<GripVertical className="size-4" />
|
|
48
|
+
</IconButton>
|
|
49
|
+
)
|
|
50
|
+
}
|